tycono 0.1.57 → 0.1.59

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.57",
3
+ "version": "0.1.59",
4
4
  "description": "Build an AI company. Watch them work.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -435,20 +435,37 @@ ${subInfo}
435
435
  **Use Bash to run the dispatch command:**
436
436
 
437
437
  \`\`\`bash
438
+ # Start a job (returns immediately with job ID)
438
439
  python3 "$DISPATCH_CMD" ${exampleSubId} "Task description here"
440
+
441
+ # Check job status/result later
442
+ python3 "$DISPATCH_CMD" --check <jobId>
443
+
444
+ # Start and wait for result (blocks up to 90s)
445
+ python3 "$DISPATCH_CMD" --wait ${exampleSubId} "Task description here"
439
446
  \`\`\`
440
447
 
441
448
  **IMPORTANT**: Always use \`python3 "$DISPATCH_CMD"\` — this is the ONLY way to dispatch tasks to subordinates.
442
449
 
443
- The command will:
444
- 1. Start a job for the subordinate
445
- 2. Wait up to ~100 seconds for completion
446
- 3. Return the subordinate's output if done, or a job ID to check later
450
+ ### Recommended Pattern: Parallel Dispatch
451
+
452
+ For multiple tasks, dispatch all at once, then check results:
447
453
 
448
- If the subordinate takes longer than 100s, you'll get a job ID. Check the result with:
449
454
  \`\`\`bash
450
- python3 "$DISPATCH_CMD" --check <jobId>
451
- \`\`\``;
455
+ # 1. Dispatch all tasks (each returns immediately)
456
+ python3 "$DISPATCH_CMD" ${exampleSubId} "Task A"
457
+ python3 "$DISPATCH_CMD" ${subordinates.length > 1 ? subordinates[1] : exampleSubId} "Task B"
458
+
459
+ # 2. Check results later (use the Job IDs from step 1)
460
+ python3 "$DISPATCH_CMD" --check <jobId-A>
461
+ python3 "$DISPATCH_CMD" --check <jobId-B>
462
+ \`\`\`
463
+
464
+ ### Status Values
465
+ - **running** — Subordinate is working
466
+ - **done** — Task completed, result available
467
+ - **error** — Task failed
468
+ - **awaiting_input** — Subordinate has a question for you`;
452
469
 
453
470
  // C-level roles get mandatory delegation rules
454
471
  if (isCLevel) {
@@ -480,19 +497,35 @@ When you receive a directive:
480
497
 
481
498
  ### The Supervision Loop (CRITICAL)
482
499
 
483
- After EVERY dispatch, follow this loop:
500
+ After dispatching tasks, follow this loop:
484
501
 
485
502
  \`\`\`
486
- DISPATCH → WAIT → REVIEW → DECIDE
487
- ├── PASS → Knowledge Update → Task Update → Next Dispatch
488
- └── FAIL → Re-dispatch with feedback
503
+ DISPATCH ALL CHECK RESULTS → REVIEW → DECIDE
504
+ ├── PASS → Knowledge Update → Task Update → Next Dispatch
505
+ └── FAIL → Re-dispatch with feedback
489
506
  \`\`\`
490
507
 
508
+ **Step 1: Dispatch all tasks** (fire-and-forget, collect job IDs)
509
+ \`\`\`bash
510
+ python3 "$DISPATCH_CMD" engineer "Task A" # → job-xxx
511
+ python3 "$DISPATCH_CMD" designer "Task B" # → job-yyy
512
+ \`\`\`
513
+
514
+ **Step 2: Check results** (after waiting, use --check with saved job IDs)
515
+ \`\`\`bash
516
+ python3 "$DISPATCH_CMD" --check <job-xxx>
517
+ python3 "$DISPATCH_CMD" --check <job-yyy>
518
+ \`\`\`
519
+
520
+ **Step 3-4: Review → Knowledge Update → Task Update → Next Dispatch**
491
521
  1. **Review**: Does the output meet acceptance criteria?
492
522
  2. **Knowledge Update**: Record decisions, findings, analysis in AKB (journals, knowledge/)
493
523
  3. **Task Update**: Update task status in tasks.md or project docs
494
524
  4. **Next Dispatch**: Identify and dispatch the next task
495
525
 
526
+ ⚠️ Do NOT use curl or other methods to create jobs — always use the dispatch command.
527
+ ⚠️ Do NOT use sleep loops to wait — use --check to poll for results.
528
+
496
529
  ### Dispatch Quality Requirements
497
530
 
498
531
  Every dispatch MUST include:
@@ -13,9 +13,10 @@ import type { ExecutionRunner, RunnerConfig, RunnerCallbacks, RunnerHandle, Runn
13
13
  const DISPATCH_SCRIPT = `#!/usr/bin/env python3
14
14
  """dispatch-bridge: CLI runner가 하위 Role에게 작업을 할당하는 브릿지 스크립트.
15
15
 
16
- 2가지 모드:
17
- dispatch <roleId> "<task>" — Job 시작 + 결과 대기 (최대 100초)
18
- dispatch --check <jobId> — 완료된 Job 결과 조회
16
+ 3가지 모드:
17
+ dispatch <roleId> "<task>" — Job 시작 (즉시 반환, 대기하지 않음)
18
+ dispatch --check <jobId> — Job 상태 및 결과 조회
19
+ dispatch --wait <roleId> "<task>" — Job 시작 + 완료 대기 (최대 90초)
19
20
 
20
21
  환경변수:
21
22
  DISPATCH_API_URL — API 서버 URL (default: http://localhost:3001)
@@ -44,14 +45,64 @@ def get_result(job_id):
44
45
  except Exception as e:
45
46
  return f'ERROR: Failed to get result: {e}'
46
47
 
48
+ def get_status(job_id):
49
+ info = json.loads(urllib.request.urlopen(f'{api}/api/jobs/{job_id}', timeout=5).read())
50
+ return info.get('status', 'unknown')
51
+
52
+ def start_job(role_id, task):
53
+ parent_job = os.environ.get('DISPATCH_PARENT_JOB', '')
54
+ source_role = os.environ.get('DISPATCH_SOURCE_ROLE', 'ceo')
55
+ body = json.dumps({
56
+ 'type': 'assign',
57
+ 'roleId': role_id,
58
+ 'task': task,
59
+ 'sourceRole': source_role,
60
+ 'parentJobId': parent_job if parent_job else None,
61
+ }).encode()
62
+ req = urllib.request.Request(f'{api}/api/jobs', body, {'Content-Type': 'application/json'})
63
+ resp = json.loads(urllib.request.urlopen(req, timeout=10).read())
64
+ return resp['jobId']
65
+
66
+ def poll_until_done(job_id, role_id, max_wait=90):
67
+ waited = 0
68
+ while waited < max_wait:
69
+ try:
70
+ status = get_status(job_id)
71
+ if status == 'done':
72
+ log('')
73
+ log(f'=== {role_id.upper()} Result (done) ===')
74
+ log(get_result(job_id))
75
+ return
76
+ elif status == 'error':
77
+ log('')
78
+ log(f'=== {role_id.upper()} Result (error) ===')
79
+ log(get_result(job_id))
80
+ return
81
+ elif status == 'awaiting_input':
82
+ log('')
83
+ log(f'{role_id.upper()} is asking a question (awaiting_input).')
84
+ log(f'Check details: python3 "$DISPATCH_CMD" --check {job_id}')
85
+ return
86
+ except Exception:
87
+ pass
88
+ time.sleep(5)
89
+ waited += 5
90
+ if waited % 15 == 0:
91
+ log(f' ... {role_id} still working ({waited}s)')
92
+ log('')
93
+ log(f'{role_id.upper()} is still working after {waited}s.')
94
+ log(f'Check result later: python3 "$DISPATCH_CMD" --check {job_id}')
95
+
47
96
  # Mode: --check <jobId>
48
97
  if len(sys.argv) >= 3 and sys.argv[1] == '--check':
49
98
  job_id = sys.argv[2]
50
99
  try:
51
- info = json.loads(urllib.request.urlopen(f'{api}/api/jobs/{job_id}', timeout=10).read())
52
- status = info.get('status', 'unknown')
100
+ status = get_status(job_id)
53
101
  if status == 'running':
54
102
  log(f'Job {job_id} is still running. Try again later.')
103
+ elif status == 'awaiting_input':
104
+ log(f'Job {job_id} is awaiting input (subordinate asked a question).')
105
+ log(get_result(job_id))
55
106
  else:
56
107
  log(f'=== Job {job_id}: {status} ===')
57
108
  log(get_result(job_id))
@@ -59,33 +110,29 @@ if len(sys.argv) >= 3 and sys.argv[1] == '--check':
59
110
  log(f'ERROR: {e}')
60
111
  sys.exit(0)
61
112
 
62
- # Mode: dispatch <roleId> "<task>"
63
- if len(sys.argv) < 3:
64
- log('Usage: dispatch <roleId> "<task>"')
65
- log(' dispatch --check <jobId>')
113
+ # Mode: --wait <roleId> "<task>" (start + wait)
114
+ wait_mode = False
115
+ args = sys.argv[1:]
116
+ if args and args[0] == '--wait':
117
+ wait_mode = True
118
+ args = args[1:]
119
+
120
+ # Usage check
121
+ if len(args) < 2:
122
+ log('Usage: dispatch <roleId> "<task>" — Start job (immediate return)')
123
+ log(' dispatch --wait <roleId> "<task>" — Start job + wait for result')
124
+ log(' dispatch --check <jobId> — Check job status/result')
66
125
  subs = os.environ.get('DISPATCH_SUBORDINATES', '')
67
126
  if subs:
68
127
  log(f'Available subordinates: {subs}')
69
128
  sys.exit(1)
70
129
 
71
- role_id = sys.argv[1]
72
- task = ' '.join(sys.argv[2:])
73
- parent_job = os.environ.get('DISPATCH_PARENT_JOB', '')
74
- source_role = os.environ.get('DISPATCH_SOURCE_ROLE', 'ceo')
130
+ role_id = args[0]
131
+ task = ' '.join(args[1:])
75
132
 
76
133
  # Start job
77
- body = json.dumps({
78
- 'type': 'assign',
79
- 'roleId': role_id,
80
- 'task': task,
81
- 'sourceRole': source_role,
82
- 'parentJobId': parent_job if parent_job else None,
83
- }).encode()
84
-
85
134
  try:
86
- req = urllib.request.Request(f'{api}/api/jobs', body, {'Content-Type': 'application/json'})
87
- resp = json.loads(urllib.request.urlopen(req, timeout=10).read())
88
- job_id = resp['jobId']
135
+ job_id = start_job(role_id, task)
89
136
  except Exception as e:
90
137
  log(f'ERROR: Failed to start dispatch job: {e}')
91
138
  sys.exit(1)
@@ -94,26 +141,13 @@ log(f'=== Dispatched to {role_id.upper()} ===')
94
141
  log(f'Task: {task[:120]}')
95
142
  log(f'Job ID: {job_id}')
96
143
 
97
- # Wait for completion (max ~100s to stay within Bash timeout)
98
- status = 'running'
99
- waited = 0
100
- while waited < 100:
101
- try:
102
- info = json.loads(urllib.request.urlopen(f'{api}/api/jobs/{job_id}', timeout=5).read())
103
- status = info.get('status', 'unknown')
104
- if status in ('done', 'error'):
105
- break
106
- except Exception:
107
- pass
108
- time.sleep(3)
109
- waited += 3
110
-
111
- if status in ('done', 'error'):
112
- log(f'\\n=== {role_id.upper()} Result ({status}) ===')
113
- log(get_result(job_id))
144
+ if wait_mode:
145
+ log(f'Waiting for completion...')
146
+ poll_until_done(job_id, role_id)
114
147
  else:
115
- log(f'\\n{role_id.upper()} is still working (waited {waited}s).')
116
- log(f'Check result later: python3 "$DISPATCH_CMD" --check {job_id}')
148
+ log('')
149
+ log(f'Job started. Check result with:')
150
+ log(f' python3 "$DISPATCH_CMD" --check {job_id}')
117
151
  `;
118
152
 
119
153
  /* ─── Consult Bridge Script (Python3) ────── */
@@ -122,7 +156,7 @@ const CONSULT_SCRIPT = `#!/usr/bin/env python3
122
156
  """consult-bridge: CLI runner가 다른 Role에게 질문하는 브릿지 스크립트.
123
157
 
124
158
  사용법:
125
- consult <roleId> "<question>" — Job 시작 (readOnly) + 결과 대기
159
+ consult <roleId> "<question>" — Job 시작 (readOnly) + 결과 대기 (최대 90초)
126
160
  consult --check <jobId> — 완료된 Job 결과 조회
127
161
 
128
162
  환경변수:
@@ -152,14 +186,20 @@ def get_result(job_id):
152
186
  except Exception as e:
153
187
  return f'ERROR: Failed to get result: {e}'
154
188
 
189
+ def get_status(job_id):
190
+ info = json.loads(urllib.request.urlopen(f'{api}/api/jobs/{job_id}', timeout=5).read())
191
+ return info.get('status', 'unknown')
192
+
155
193
  # Mode: --check <jobId>
156
194
  if len(sys.argv) >= 3 and sys.argv[1] == '--check':
157
195
  job_id = sys.argv[2]
158
196
  try:
159
- info = json.loads(urllib.request.urlopen(f'{api}/api/jobs/{job_id}', timeout=10).read())
160
- status = info.get('status', 'unknown')
197
+ status = get_status(job_id)
161
198
  if status == 'running':
162
199
  log(f'Job {job_id} is still running. Try again later.')
200
+ elif status == 'awaiting_input':
201
+ log(f'Job {job_id} is awaiting input.')
202
+ log(get_result(job_id))
163
203
  else:
164
204
  log(f'=== Job {job_id}: {status} ===')
165
205
  log(get_result(job_id))
@@ -200,27 +240,37 @@ except Exception as e:
200
240
  log(f'=== Consulting {role_id.upper()} ===')
201
241
  log(f'Question: {question[:120]}')
202
242
  log(f'Job ID: {job_id}')
243
+ log(f'Waiting for answer...')
203
244
 
204
- # Wait for completion (max ~100s)
205
- status = 'running'
245
+ # Wait for completion (max ~90s — consult is read-only, usually fast)
206
246
  waited = 0
207
- while waited < 100:
247
+ while waited < 90:
208
248
  try:
209
- info = json.loads(urllib.request.urlopen(f'{api}/api/jobs/{job_id}', timeout=5).read())
210
- status = info.get('status', 'unknown')
211
- if status in ('done', 'error'):
212
- break
249
+ status = get_status(job_id)
250
+ if status == 'done':
251
+ log('')
252
+ log(f'=== {role_id.upper()} Answer ===')
253
+ log(get_result(job_id))
254
+ sys.exit(0)
255
+ elif status == 'error':
256
+ log('')
257
+ log(f'=== {role_id.upper()} Error ===')
258
+ log(get_result(job_id))
259
+ sys.exit(1)
260
+ elif status == 'awaiting_input':
261
+ log('')
262
+ log(f'{role_id.upper()} needs clarification. Check: python3 "$CONSULT_CMD" --check {job_id}')
263
+ sys.exit(0)
213
264
  except Exception:
214
265
  pass
215
- time.sleep(3)
216
- waited += 3
217
-
218
- if status in ('done', 'error'):
219
- log(f'\\n=== {role_id.upper()} Answer ({status}) ===')
220
- log(get_result(job_id))
221
- else:
222
- log(f'\\n{role_id.upper()} is still thinking (waited {waited}s).')
223
- log(f'Check result later: python3 "$CONSULT_CMD" --check {job_id}')
266
+ time.sleep(5)
267
+ waited += 5
268
+ if waited % 15 == 0:
269
+ log(f' ... {role_id} still thinking ({waited}s)')
270
+
271
+ log('')
272
+ log(f'{role_id.upper()} is still thinking after {waited}s.')
273
+ log(f'Check result later: python3 "$CONSULT_CMD" --check {job_id}')
224
274
  `;
225
275
 
226
276
  /* ─── Claude CLI Runner ──────────────────────── */
@@ -1,23 +1,30 @@
1
1
  import { Router, Request, Response, NextFunction } from 'express';
2
2
  import { COMPANY_ROOT } from '../services/file-reader.js';
3
- import { getGitStatus, gitSave, gitHistory, gitRestore, gitInit } from '../services/git-save.js';
3
+ import { getGitStatus, gitSave, gitHistory, gitRestore, gitInit, gitFetchStatus, gitPull } from '../services/git-save.js';
4
+ import type { RepoType } from '../services/git-save.js';
4
5
 
5
6
  export const saveRouter = Router();
6
7
 
7
- // GET /api/save/status
8
- saveRouter.get('/status', (_req: Request, res: Response, next: NextFunction) => {
8
+ /** Extract repo type from query param, default 'akb' */
9
+ function getRepo(req: Request): RepoType {
10
+ const repo = req.query.repo;
11
+ return repo === 'code' ? 'code' : 'akb';
12
+ }
13
+
14
+ // GET /api/save/status?repo=akb|code
15
+ saveRouter.get('/status', (req: Request, res: Response, next: NextFunction) => {
9
16
  try {
10
- res.json(getGitStatus(COMPANY_ROOT));
17
+ res.json(getGitStatus(COMPANY_ROOT, getRepo(req)));
11
18
  } catch (err) {
12
19
  next(err);
13
20
  }
14
21
  });
15
22
 
16
- // POST /api/save — commit + push
23
+ // POST /api/save?repo=akb|code — commit + push
17
24
  saveRouter.post('/', (req: Request, res: Response, next: NextFunction) => {
18
25
  try {
19
26
  const { message } = req.body ?? {};
20
- const result = gitSave(COMPANY_ROOT, message);
27
+ const result = gitSave(COMPANY_ROOT, message, getRepo(req));
21
28
  res.json({ ok: true, ...result });
22
29
  } catch (err) {
23
30
  if (err instanceof Error && err.message === 'No changes to save') {
@@ -28,11 +35,11 @@ saveRouter.post('/', (req: Request, res: Response, next: NextFunction) => {
28
35
  }
29
36
  });
30
37
 
31
- // GET /api/save/history
38
+ // GET /api/save/history?repo=akb|code
32
39
  saveRouter.get('/history', (req: Request, res: Response, next: NextFunction) => {
33
40
  try {
34
41
  const limit = Math.min(Number(req.query.limit) || 20, 100);
35
- res.json(gitHistory(COMPANY_ROOT, limit));
42
+ res.json(gitHistory(COMPANY_ROOT, limit, getRepo(req)));
36
43
  } catch (err) {
37
44
  next(err);
38
45
  }
@@ -66,3 +73,26 @@ saveRouter.post('/restore', (req: Request, res: Response, next: NextFunction) =>
66
73
  next(err);
67
74
  }
68
75
  });
76
+
77
+ // GET /api/save/sync-status?repo=akb|code — fetch + ahead/behind
78
+ saveRouter.get('/sync-status', (req: Request, res: Response, next: NextFunction) => {
79
+ try {
80
+ res.json(gitFetchStatus(COMPANY_ROOT, getRepo(req)));
81
+ } catch (err) {
82
+ next(err);
83
+ }
84
+ });
85
+
86
+ // POST /api/save/pull?repo=akb|code — safe pull (ff-only)
87
+ saveRouter.post('/pull', (req: Request, res: Response, next: NextFunction) => {
88
+ try {
89
+ const result = gitPull(COMPANY_ROOT, getRepo(req));
90
+ const statusCode = result.status === 'ok' || result.status === 'up-to-date' ? 200
91
+ : result.status === 'dirty' || result.status === 'diverged' ? 409
92
+ : result.status === 'no-remote' ? 404
93
+ : 500;
94
+ res.status(statusCode).json(result);
95
+ } catch (err) {
96
+ next(err);
97
+ }
98
+ });
@@ -278,6 +278,16 @@ setupRouter.post('/import-knowledge', (req, res) => {
278
278
  });
279
279
  });
280
280
 
281
+ /**
282
+ * GET /api/setup/code-root
283
+ * Get the current codeRoot config value.
284
+ */
285
+ setupRouter.get('/code-root', (_req, res) => {
286
+ const companyRoot = process.env.COMPANY_ROOT || process.cwd();
287
+ const config = readConfig(companyRoot);
288
+ res.json({ codeRoot: config.codeRoot || null });
289
+ });
290
+
281
291
  /**
282
292
  * POST /api/setup/code-root
283
293
  * Set or update the codeRoot config field.
@@ -297,10 +307,24 @@ setupRouter.post('/code-root', (req, res) => {
297
307
  return;
298
308
  }
299
309
 
310
+ if (!fs.statSync(resolved).isDirectory()) {
311
+ res.status(400).json({ ok: false, error: 'Path is not a directory' });
312
+ return;
313
+ }
314
+
315
+ // Check if it's a git repository
316
+ let isGitRepo = false;
317
+ try {
318
+ execSync('git rev-parse --git-dir', { cwd: resolved, timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] });
319
+ isGitRepo = true;
320
+ } catch {
321
+ // Not a git repo — still allow, but inform
322
+ }
323
+
300
324
  const config = readConfig(companyRoot);
301
325
  writeConfig(companyRoot, { ...config, codeRoot: resolved });
302
326
 
303
- res.json({ ok: true, codeRoot: resolved });
327
+ res.json({ ok: true, codeRoot: resolved, isGitRepo });
304
328
  });
305
329
 
306
330
  /**
@@ -50,6 +50,22 @@ export interface RestoreResult {
50
50
  restoredFiles: string[];
51
51
  }
52
52
 
53
+ export interface PullResult {
54
+ status: 'ok' | 'dirty' | 'diverged' | 'up-to-date' | 'no-remote' | 'error';
55
+ message: string;
56
+ commits?: number;
57
+ behind?: number;
58
+ ahead?: number;
59
+ }
60
+
61
+ export interface SyncStatus {
62
+ ahead: number;
63
+ behind: number;
64
+ branch: string;
65
+ remote: string;
66
+ hasRemote: boolean;
67
+ }
68
+
53
69
  /**
54
70
  * Paths to include in save (relative to root).
55
71
  * Only AKB files — never user source code.
@@ -356,3 +372,102 @@ export function gitRestore(root: string, sha: string, paths?: string[], repo: Re
356
372
 
357
373
  return { commitSha: newSha, restoredFiles };
358
374
  }
375
+
376
+ /**
377
+ * Fetch remote and return ahead/behind status
378
+ * @param root - AKB repository root (COMPANY_ROOT)
379
+ * @param repo - Repository type ('akb' or 'code'), default 'akb'
380
+ */
381
+ export function gitFetchStatus(root: string, repo: RepoType = 'akb'): SyncStatus {
382
+ const repoRoot = resolveRepoRoot(root, repo);
383
+
384
+ if (!isGitRepo(repoRoot)) {
385
+ return { ahead: 0, behind: 0, branch: '', remote: '', hasRemote: false };
386
+ }
387
+
388
+ const branch = run('git rev-parse --abbrev-ref HEAD', repoRoot) || 'unknown';
389
+ const hasRemote = !!run('git remote', repoRoot);
390
+
391
+ if (!hasRemote) {
392
+ return { ahead: 0, behind: 0, branch, remote: '', hasRemote: false };
393
+ }
394
+
395
+ // Fetch from remote (timeout 15s for network)
396
+ try {
397
+ execSync('git fetch origin', { cwd: repoRoot, encoding: 'utf-8', timeout: 15000, stdio: ['pipe', 'pipe', 'pipe'] });
398
+ } catch {
399
+ // Fetch failed (no network, etc.) — return what we know
400
+ return { ahead: 0, behind: 0, branch, remote: 'origin', hasRemote: true };
401
+ }
402
+
403
+ // Count ahead/behind
404
+ const revList = run(`git rev-list --left-right --count HEAD...origin/${branch}`, repoRoot);
405
+ let ahead = 0;
406
+ let behind = 0;
407
+ if (revList) {
408
+ const parts = revList.split(/\s+/);
409
+ ahead = parseInt(parts[0], 10) || 0;
410
+ behind = parseInt(parts[1], 10) || 0;
411
+ }
412
+
413
+ return { ahead, behind, branch, remote: 'origin', hasRemote: true };
414
+ }
415
+
416
+ /**
417
+ * Safe pull (fast-forward only)
418
+ * @param root - AKB repository root (COMPANY_ROOT)
419
+ * @param repo - Repository type ('akb' or 'code'), default 'akb'
420
+ */
421
+ export function gitPull(root: string, repo: RepoType = 'akb'): PullResult {
422
+ const repoRoot = resolveRepoRoot(root, repo);
423
+
424
+ if (!isGitRepo(repoRoot)) {
425
+ return { status: 'error', message: 'Not a git repository' };
426
+ }
427
+
428
+ const branch = run('git rev-parse --abbrev-ref HEAD', repoRoot) || 'unknown';
429
+ const hasRemote = !!run('git remote', repoRoot);
430
+
431
+ if (!hasRemote) {
432
+ return { status: 'no-remote', message: 'No remote configured' };
433
+ }
434
+
435
+ // Fetch first
436
+ try {
437
+ execSync('git fetch origin', { cwd: repoRoot, encoding: 'utf-8', timeout: 15000, stdio: ['pipe', 'pipe', 'pipe'] });
438
+ } catch {
439
+ return { status: 'error', message: 'Failed to fetch from remote' };
440
+ }
441
+
442
+ // Check for uncommitted changes
443
+ const porcelain = run('git status --porcelain', repoRoot);
444
+ if (porcelain) {
445
+ return { status: 'dirty', message: 'Uncommitted changes — save or stash before pulling' };
446
+ }
447
+
448
+ // Check ahead/behind
449
+ const revList = run(`git rev-list --left-right --count HEAD...origin/${branch}`, repoRoot);
450
+ let ahead = 0;
451
+ let behind = 0;
452
+ if (revList) {
453
+ const parts = revList.split(/\s+/);
454
+ ahead = parseInt(parts[0], 10) || 0;
455
+ behind = parseInt(parts[1], 10) || 0;
456
+ }
457
+
458
+ if (behind === 0) {
459
+ return { status: 'up-to-date', message: 'Already up to date', ahead, behind: 0 };
460
+ }
461
+
462
+ if (ahead > 0 && behind > 0) {
463
+ return { status: 'diverged', message: `Branches diverged (${ahead} ahead, ${behind} behind) — manual merge needed`, ahead, behind };
464
+ }
465
+
466
+ // Safe fast-forward pull
467
+ try {
468
+ runOrThrow(`git pull --ff-only origin ${branch}`, repoRoot);
469
+ return { status: 'ok', message: `Pulled ${behind} commit(s)`, commits: behind, ahead: 0, behind: 0 };
470
+ } catch (err) {
471
+ return { status: 'error', message: err instanceof Error ? err.message : 'Pull failed' };
472
+ }
473
+ }
@@ -1,39 +1,26 @@
1
1
  /**
2
2
  * role-level.ts — Server-side role level calculation
3
3
  *
4
- * Mirrors the frontend level thresholds so the chat system prompt
5
- * can reference a role's current level and team stats.
4
+ * Mirrors the frontend level system.
5
+ * Formula: level = floor(√(tokens ÷ 50,000))
6
+ * Infinite levels, quadratic scaling.
6
7
  */
7
8
 
8
- const THRESHOLDS = [
9
- 0, // Lv.1
10
- 10_000, // Lv.2
11
- 50_000, // Lv.3
12
- 150_000, // Lv.4
13
- 400_000, // Lv.5
14
- 1_000_000, // Lv.6
15
- 2_500_000, // Lv.7
16
- 5_000_000, // Lv.8
17
- 10_000_000, // Lv.9
18
- 25_000_000, // Lv.10
19
- ];
9
+ const BASE_XP = 50_000;
20
10
 
21
11
  export function calcLevel(totalTokens: number): number {
22
- let level = 1;
23
- for (let i = THRESHOLDS.length - 1; i >= 0; i--) {
24
- if (totalTokens >= THRESHOLDS[i]) {
25
- level = i + 1;
26
- break;
27
- }
28
- }
29
- return Math.min(level, 10);
12
+ if (totalTokens < BASE_XP) return 1;
13
+ return Math.max(1, Math.floor(Math.sqrt(totalTokens / BASE_XP)));
14
+ }
15
+
16
+ export function tokensForLevel(level: number): number {
17
+ return BASE_XP * level * level;
30
18
  }
31
19
 
32
20
  export function calcProgress(totalTokens: number): number {
33
21
  const level = calcLevel(totalTokens);
34
- if (level >= 10) return 1;
35
- const current = THRESHOLDS[level - 1];
36
- const next = THRESHOLDS[level];
22
+ const current = tokensForLevel(level);
23
+ const next = tokensForLevel(level + 1);
37
24
  return Math.min(1, (totalTokens - current) / (next - current));
38
25
  }
39
26