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,77 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import matter from 'gray-matter';
4
+ import { glob } from 'glob';
5
+
6
+ function findCompanyRoot(): string {
7
+ if (process.env.COMPANY_ROOT) return process.env.COMPANY_ROOT;
8
+ // Walk up from cwd to find knowledge/CLAUDE.md (project root marker)
9
+ let dir = process.cwd();
10
+ while (dir !== path.dirname(dir)) {
11
+ if (fs.existsSync(path.join(dir, 'knowledge', 'CLAUDE.md'))) return dir;
12
+ dir = path.dirname(dir);
13
+ }
14
+ return process.cwd();
15
+ }
16
+
17
+ export let COMPANY_ROOT = findCompanyRoot();
18
+
19
+ /** Update COMPANY_ROOT at runtime (e.g. after scaffold picks a new location) */
20
+ export function setCompanyRoot(root: string): void {
21
+ COMPANY_ROOT = root;
22
+ process.env.COMPANY_ROOT = root;
23
+ }
24
+
25
+ function resolve(...segments: string[]): string {
26
+ return path.resolve(COMPANY_ROOT, ...segments);
27
+ }
28
+
29
+ /**
30
+ * Markdown 파일을 읽고 frontmatter와 content를 분리하여 반환한다.
31
+ */
32
+ export function readMarkdown(filePath: string): { frontmatter: Record<string, unknown>; content: string } {
33
+ const absolute = resolve(filePath);
34
+ if (!fs.existsSync(absolute)) {
35
+ throw new FileNotFoundError(filePath);
36
+ }
37
+ const raw = fs.readFileSync(absolute, 'utf-8');
38
+ const { data, content } = matter(raw);
39
+ return { frontmatter: data, content };
40
+ }
41
+
42
+ /**
43
+ * 파일의 raw text를 반환한다.
44
+ */
45
+ export function readFile(filePath: string): string {
46
+ const absolute = resolve(filePath);
47
+ if (!fs.existsSync(absolute)) {
48
+ throw new FileNotFoundError(filePath);
49
+ }
50
+ return fs.readFileSync(absolute, 'utf-8');
51
+ }
52
+
53
+ /**
54
+ * 디렉토리 내 파일 목록을 반환한다.
55
+ * pattern 미지정 시 *.md 파일만 반환.
56
+ */
57
+ export function listFiles(dirPath: string, pattern = '*.md'): string[] {
58
+ const absolute = resolve(dirPath);
59
+ if (!fs.existsSync(absolute)) {
60
+ return [];
61
+ }
62
+ return glob.sync(pattern, { cwd: absolute }).sort();
63
+ }
64
+
65
+ /**
66
+ * 파일 존재 여부를 확인한다.
67
+ */
68
+ export function fileExists(filePath: string): boolean {
69
+ return fs.existsSync(resolve(filePath));
70
+ }
71
+
72
+ export class FileNotFoundError extends Error {
73
+ constructor(filePath: string) {
74
+ super(`File not found: ${filePath}`);
75
+ this.name = 'FileNotFoundError';
76
+ }
77
+ }
@@ -0,0 +1,614 @@
1
+ /**
2
+ * git-save.ts — Git commit + push 기반 세이브 시스템
3
+ *
4
+ * 모든 진행 상황을 GitHub에 영속화한다.
5
+ * 의존성 0: child_process.execSync만 사용.
6
+ *
7
+ * 핵심 원칙 (data-persistence-architecture.md):
8
+ * - SAVE_PATHS의 AKB 파일만 stage (유저 소스코드 미포함)
9
+ * - git 없으면 graceful 비활성화
10
+ * - remote 없으면 commit만 (push skip)
11
+ *
12
+ * Dual-Repo Support (DG-001):
13
+ * - repo='akb': AKB repo (COMPANY_ROOT), SAVE_PATHS 필터 적용
14
+ * - repo='code': Code repo (codeRoot), SAVE_PATHS 필터 비활성화
15
+ */
16
+ import { execSync } from 'node:child_process';
17
+ import { readFileSync } from 'node:fs';
18
+ import { join, resolve } from 'node:path';
19
+ import { resolveCodeRoot } from './company-config.js';
20
+
21
+ export type RepoType = 'akb' | 'code';
22
+
23
+ export interface GitStatus {
24
+ dirty: boolean;
25
+ modified: string[];
26
+ untracked: string[];
27
+ lastCommit: { sha: string; message: string; date: string } | null;
28
+ branch: string;
29
+ hasRemote: boolean;
30
+ synced: boolean;
31
+ noGit: boolean;
32
+ }
33
+
34
+ export interface SaveResult {
35
+ commitSha: string;
36
+ message: string;
37
+ filesChanged: number;
38
+ pushed: boolean;
39
+ pushError?: string;
40
+ }
41
+
42
+ export interface CommitInfo {
43
+ sha: string;
44
+ shortSha: string;
45
+ message: string;
46
+ date: string;
47
+ }
48
+
49
+ export interface RestoreResult {
50
+ commitSha: string;
51
+ restoredFiles: string[];
52
+ }
53
+
54
+ export interface PullResult {
55
+ status: 'ok' | 'dirty' | 'diverged' | 'up-to-date' | 'no-remote' | 'error';
56
+ message: string;
57
+ commits?: number;
58
+ behind?: number;
59
+ ahead?: number;
60
+ }
61
+
62
+ export interface SyncStatus {
63
+ ahead: number;
64
+ behind: number;
65
+ branch: string;
66
+ remote: string;
67
+ hasRemote: boolean;
68
+ }
69
+
70
+ /**
71
+ * Paths to include in save (relative to root).
72
+ * Only AKB files — never user source code.
73
+ * See: knowledge/data-persistence-architecture.md §2
74
+ */
75
+ const SAVE_PATHS = [
76
+ 'knowledge/',
77
+ '.claude/skills/',
78
+ '.tycono/',
79
+ 'CLAUDE.md',
80
+ ];
81
+
82
+ /**
83
+ * Resolve repository root based on repo type
84
+ * @param akbRoot - AKB repository root (COMPANY_ROOT)
85
+ * @param repo - Repository type ('akb' or 'code')
86
+ * @returns Resolved repository root path
87
+ */
88
+ function resolveRepoRoot(akbRoot: string, repo: RepoType = 'akb'): string {
89
+ if (repo === 'akb') {
90
+ return akbRoot;
91
+ }
92
+ return resolveCodeRoot(akbRoot);
93
+ }
94
+
95
+ /**
96
+ * Check if SAVE_PATHS filter should be applied
97
+ * @param repo - Repository type
98
+ * @returns true if filter should be applied (AKB only)
99
+ */
100
+ function shouldFilterPaths(repo: RepoType = 'akb'): boolean {
101
+ return repo === 'akb';
102
+ }
103
+
104
+ function run(cmd: string, cwd: string): string {
105
+ try {
106
+ return execSync(cmd, { cwd, encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] }).trim();
107
+ } catch {
108
+ return '';
109
+ }
110
+ }
111
+
112
+ function runOrThrow(cmd: string, cwd: string): string {
113
+ return execSync(cmd, { cwd, encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] }).trim();
114
+ }
115
+
116
+ /** Check if git binary is available */
117
+ function isGitAvailable(): boolean {
118
+ try {
119
+ execSync('git --version', { timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] });
120
+ return true;
121
+ } catch {
122
+ return false;
123
+ }
124
+ }
125
+
126
+ /** Check if directory is (or is inside) a git repository */
127
+ function isGitRepo(root: string): boolean {
128
+ return run('git rev-parse --is-inside-work-tree', root) === 'true';
129
+ }
130
+
131
+ /** Check if directory is the root of its own git repository (has .git here) */
132
+ function isGitRoot(root: string): boolean {
133
+ const toplevel = run('git rev-parse --show-toplevel', root);
134
+ if (!toplevel) return false;
135
+ return resolve(toplevel) === resolve(root);
136
+ }
137
+
138
+ /**
139
+ * Initialize a new git repository
140
+ * @param root - AKB repository root (COMPANY_ROOT)
141
+ * @param repo - Repository type ('akb' or 'code'), default 'akb'
142
+ */
143
+ export function gitInit(root: string, repo: RepoType = 'akb'): { ok: boolean; message: string; noGitBinary?: boolean } {
144
+ if (!isGitAvailable()) {
145
+ return { ok: false, message: 'git is not installed', noGitBinary: true };
146
+ }
147
+
148
+ const repoRoot = resolveRepoRoot(root, repo);
149
+
150
+ if (isGitRoot(repoRoot)) {
151
+ return { ok: true, message: 'Already a git repository' };
152
+ }
153
+ try {
154
+ runOrThrow('git init', repoRoot);
155
+ runOrThrow('git add -A', repoRoot);
156
+ runOrThrow('git commit -m "Initial commit by Tycono"', repoRoot);
157
+ return { ok: true, message: 'Git repository initialized with initial commit' };
158
+ } catch (err) {
159
+ return { ok: false, message: err instanceof Error ? err.message : 'git init failed' };
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Get current git status. Returns noGit=true if not a git repo.
165
+ * @param root - AKB repository root (COMPANY_ROOT)
166
+ * @param repo - Repository type ('akb' or 'code'), default 'akb'
167
+ */
168
+ export function getGitStatus(root: string, repo: RepoType = 'akb'): GitStatus {
169
+ const repoRoot = resolveRepoRoot(root, repo);
170
+
171
+ if (!isGitRoot(repoRoot)) {
172
+ return {
173
+ dirty: false,
174
+ modified: [],
175
+ untracked: [],
176
+ lastCommit: null,
177
+ branch: '',
178
+ hasRemote: false,
179
+ synced: false,
180
+ noGit: true,
181
+ };
182
+ }
183
+
184
+ const porcelain = run('git status --porcelain', repoRoot);
185
+ const lines = porcelain ? porcelain.split('\n').filter(Boolean) : [];
186
+
187
+ const modified: string[] = [];
188
+ const untracked: string[] = [];
189
+ const applyFilter = shouldFilterPaths(repo);
190
+
191
+ for (const line of lines) {
192
+ const status = line.substring(0, 2);
193
+ const file = line.substring(3);
194
+
195
+ // For CODE repo, include all files; for AKB, filter to SAVE_PATHS only
196
+ if (applyFilter && !SAVE_PATHS.some(p => file.startsWith(p))) {
197
+ continue;
198
+ }
199
+
200
+ if (status.includes('?')) {
201
+ untracked.push(file);
202
+ } else {
203
+ modified.push(file);
204
+ }
205
+ }
206
+
207
+ let lastCommit: GitStatus['lastCommit'] = null;
208
+ const logLine = run('git log -1 --format=%H%n%s%n%aI', repoRoot);
209
+ if (logLine) {
210
+ const [sha, message, date] = logLine.split('\n');
211
+ if (sha) lastCommit = { sha, message: message ?? '', date: date ?? '' };
212
+ }
213
+
214
+ const branch = run('git rev-parse --abbrev-ref HEAD', repoRoot) || 'unknown';
215
+ const hasRemote = !!run('git remote', repoRoot);
216
+
217
+ let synced = true;
218
+ if (hasRemote) {
219
+ const local = run('git rev-parse HEAD', repoRoot);
220
+ const remote = run(`git rev-parse origin/${branch}`, repoRoot);
221
+ synced = !!local && local === remote;
222
+ }
223
+
224
+ return {
225
+ dirty: modified.length > 0 || untracked.length > 0,
226
+ modified,
227
+ untracked,
228
+ lastCommit,
229
+ branch,
230
+ hasRemote,
231
+ synced,
232
+ noGit: false,
233
+ };
234
+ }
235
+
236
+ /**
237
+ * Commit + push save-tracked files
238
+ * @param root - AKB repository root (COMPANY_ROOT)
239
+ * @param message - Optional commit message
240
+ * @param repo - Repository type ('akb' or 'code'), default 'akb'
241
+ */
242
+ export function gitSave(root: string, message?: string, repo: RepoType = 'akb'): SaveResult {
243
+ const repoRoot = resolveRepoRoot(root, repo);
244
+
245
+ if (!isGitRoot(repoRoot)) {
246
+ throw new Error('Not a git repository. Run "git init" first.');
247
+ }
248
+
249
+ const status = getGitStatus(root, repo);
250
+ if (!status.dirty) {
251
+ throw new Error('No changes to save');
252
+ }
253
+
254
+ const allFiles = [...status.modified, ...status.untracked];
255
+
256
+ // Stage files
257
+ for (const file of allFiles) {
258
+ runOrThrow(`git add "${file}"`, repoRoot);
259
+ }
260
+
261
+ const prefix = '[tycono] ';
262
+ const commitMsg = message
263
+ ? `${prefix}${message}`
264
+ : `${prefix}Save — ${new Date().toISOString().slice(0, 16)} (${allFiles.length} files)`;
265
+ runOrThrow(`git commit -m "${commitMsg.replace(/"/g, '\\"')}"`, repoRoot);
266
+
267
+ const sha = run('git rev-parse HEAD', repoRoot);
268
+
269
+ let pushed = false;
270
+ let pushError: string | undefined;
271
+ if (status.hasRemote) {
272
+ try {
273
+ runOrThrow(`git push origin ${status.branch}`, repoRoot);
274
+ pushed = true;
275
+ } catch (err) {
276
+ pushError = err instanceof Error ? err.message : 'Push failed';
277
+ }
278
+ }
279
+
280
+ return {
281
+ commitSha: sha,
282
+ message: commitMsg,
283
+ filesChanged: allFiles.length,
284
+ pushed,
285
+ pushError,
286
+ };
287
+ }
288
+
289
+ /**
290
+ * Get commit history
291
+ * @param root - AKB repository root (COMPANY_ROOT)
292
+ * @param limit - Maximum number of commits to retrieve
293
+ * @param repo - Repository type ('akb' or 'code'), default 'akb'
294
+ */
295
+ export function gitHistory(root: string, limit = 20, repo: RepoType = 'akb'): CommitInfo[] {
296
+ const repoRoot = resolveRepoRoot(root, repo);
297
+
298
+ if (!isGitRoot(repoRoot)) return [];
299
+
300
+ const log = run(`git log --format=%H%n%h%n%s%n%aI -n ${limit}`, repoRoot);
301
+ if (!log) return [];
302
+
303
+ const lines = log.split('\n');
304
+ const commits: CommitInfo[] = [];
305
+
306
+ for (let i = 0; i + 3 < lines.length; i += 4) {
307
+ commits.push({
308
+ sha: lines[i],
309
+ shortSha: lines[i + 1],
310
+ message: lines[i + 2],
311
+ date: lines[i + 3],
312
+ });
313
+ }
314
+
315
+ return commits;
316
+ }
317
+
318
+ /**
319
+ * Restore files from a previous commit (non-destructive: creates new commit)
320
+ * @param root - AKB repository root (COMPANY_ROOT)
321
+ * @param sha - Commit SHA to restore from
322
+ * @param paths - Optional paths to restore (defaults to SAVE_PATHS for AKB, all for CODE)
323
+ * @param repo - Repository type ('akb' or 'code'), default 'akb'
324
+ */
325
+ export function gitRestore(root: string, sha: string, paths?: string[], repo: RepoType = 'akb'): RestoreResult {
326
+ const repoRoot = resolveRepoRoot(root, repo);
327
+
328
+ if (!isGitRoot(repoRoot)) {
329
+ throw new Error('Not a git repository');
330
+ }
331
+
332
+ // For CODE repo without explicit paths, restore everything (use '.')
333
+ // For AKB repo, use SAVE_PATHS
334
+ const targetPaths = paths?.length ? paths : (repo === 'code' ? ['.'] : SAVE_PATHS);
335
+ const restoredFiles: string[] = [];
336
+
337
+ for (const p of targetPaths) {
338
+ try {
339
+ runOrThrow(`git checkout ${sha} -- "${p}"`, repoRoot);
340
+ restoredFiles.push(p);
341
+ } catch {
342
+ // Path may not exist in that commit — skip
343
+ }
344
+ }
345
+
346
+ if (restoredFiles.length === 0) {
347
+ throw new Error('No files could be restored from that commit');
348
+ }
349
+
350
+ const msg = `[tycono] Restore from ${sha.slice(0, 7)} (${restoredFiles.length} paths)`;
351
+ runOrThrow('git add -A', repoRoot);
352
+ runOrThrow(`git commit -m "${msg}"`, repoRoot);
353
+
354
+ const newSha = run('git rev-parse HEAD', repoRoot);
355
+
356
+ return { commitSha: newSha, restoredFiles };
357
+ }
358
+
359
+ /**
360
+ * Fetch remote and return ahead/behind status
361
+ * @param root - AKB repository root (COMPANY_ROOT)
362
+ * @param repo - Repository type ('akb' or 'code'), default 'akb'
363
+ */
364
+ export function gitFetchStatus(root: string, repo: RepoType = 'akb'): SyncStatus {
365
+ const repoRoot = resolveRepoRoot(root, repo);
366
+
367
+ if (!isGitRoot(repoRoot)) {
368
+ return { ahead: 0, behind: 0, branch: '', remote: '', hasRemote: false };
369
+ }
370
+
371
+ const branch = run('git rev-parse --abbrev-ref HEAD', repoRoot) || 'unknown';
372
+ const hasRemote = !!run('git remote', repoRoot);
373
+
374
+ if (!hasRemote) {
375
+ return { ahead: 0, behind: 0, branch, remote: '', hasRemote: false };
376
+ }
377
+
378
+ // Fetch from remote (timeout 15s for network)
379
+ try {
380
+ execSync('git fetch origin', { cwd: repoRoot, encoding: 'utf-8', timeout: 15000, stdio: ['pipe', 'pipe', 'pipe'] });
381
+ } catch {
382
+ // Fetch failed (no network, etc.) — return what we know
383
+ return { ahead: 0, behind: 0, branch, remote: 'origin', hasRemote: true };
384
+ }
385
+
386
+ // Count ahead/behind
387
+ const revList = run(`git rev-list --left-right --count HEAD...origin/${branch}`, repoRoot);
388
+ let ahead = 0;
389
+ let behind = 0;
390
+ if (revList) {
391
+ const parts = revList.split(/\s+/);
392
+ ahead = parseInt(parts[0], 10) || 0;
393
+ behind = parseInt(parts[1], 10) || 0;
394
+ }
395
+
396
+ return { ahead, behind, branch, remote: 'origin', hasRemote: true };
397
+ }
398
+
399
+ /**
400
+ * Safe pull (fast-forward only)
401
+ * @param root - AKB repository root (COMPANY_ROOT)
402
+ * @param repo - Repository type ('akb' or 'code'), default 'akb'
403
+ */
404
+ export function gitPull(root: string, repo: RepoType = 'akb'): PullResult {
405
+ const repoRoot = resolveRepoRoot(root, repo);
406
+
407
+ if (!isGitRoot(repoRoot)) {
408
+ return { status: 'error', message: 'Not a git repository' };
409
+ }
410
+
411
+ const branch = run('git rev-parse --abbrev-ref HEAD', repoRoot) || 'unknown';
412
+ const hasRemote = !!run('git remote', repoRoot);
413
+
414
+ if (!hasRemote) {
415
+ return { status: 'no-remote', message: 'No remote configured' };
416
+ }
417
+
418
+ // Fetch first
419
+ try {
420
+ execSync('git fetch origin', { cwd: repoRoot, encoding: 'utf-8', timeout: 15000, stdio: ['pipe', 'pipe', 'pipe'] });
421
+ } catch {
422
+ return { status: 'error', message: 'Failed to fetch from remote' };
423
+ }
424
+
425
+ // Check for uncommitted changes
426
+ const porcelain = run('git status --porcelain', repoRoot);
427
+ if (porcelain) {
428
+ return { status: 'dirty', message: 'Uncommitted changes — save or stash before pulling' };
429
+ }
430
+
431
+ // Check ahead/behind
432
+ const revList = run(`git rev-list --left-right --count HEAD...origin/${branch}`, repoRoot);
433
+ let ahead = 0;
434
+ let behind = 0;
435
+ if (revList) {
436
+ const parts = revList.split(/\s+/);
437
+ ahead = parseInt(parts[0], 10) || 0;
438
+ behind = parseInt(parts[1], 10) || 0;
439
+ }
440
+
441
+ if (behind === 0) {
442
+ return { status: 'up-to-date', message: 'Already up to date', ahead, behind: 0 };
443
+ }
444
+
445
+ if (ahead > 0 && behind > 0) {
446
+ return { status: 'diverged', message: `Branches diverged (${ahead} ahead, ${behind} behind) — manual merge needed`, ahead, behind };
447
+ }
448
+
449
+ // Safe fast-forward pull
450
+ try {
451
+ runOrThrow(`git pull --ff-only origin ${branch}`, repoRoot);
452
+ return { status: 'ok', message: `Pulled ${behind} commit(s)`, commits: behind, ahead: 0, behind: 0 };
453
+ } catch (err) {
454
+ return { status: 'error', message: err instanceof Error ? err.message : 'Pull failed' };
455
+ }
456
+ }
457
+
458
+ // ─── GitHub Integration ───────────────────────────
459
+
460
+ export interface GitHubStatus {
461
+ ghInstalled: boolean;
462
+ authenticated: boolean;
463
+ username?: string;
464
+ hasRemote: boolean;
465
+ remoteUrl?: string;
466
+ }
467
+
468
+ export interface GitHubCreateResult {
469
+ ok: boolean;
470
+ message: string;
471
+ repoUrl?: string;
472
+ remoteUrl?: string;
473
+ }
474
+
475
+ /**
476
+ * Check GitHub CLI availability and auth status
477
+ */
478
+ export function githubStatus(root: string, repo: RepoType = 'akb'): GitHubStatus {
479
+ // Check gh CLI
480
+ let ghInstalled = false;
481
+ try {
482
+ execSync('gh --version', { timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] });
483
+ ghInstalled = true;
484
+ } catch {
485
+ // gh not installed
486
+ }
487
+
488
+ if (!ghInstalled) {
489
+ return { ghInstalled: false, authenticated: false, hasRemote: false };
490
+ }
491
+
492
+ // Check auth
493
+ let authenticated = false;
494
+ let username: string | undefined;
495
+ try {
496
+ const status = execSync('gh auth status', {
497
+ timeout: 5000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'],
498
+ }).trim();
499
+ authenticated = true;
500
+ const match = status.match(/Logged in to github\.com account (\S+)/i)
501
+ ?? status.match(/account (\S+)/i);
502
+ if (match) username = match[1];
503
+ } catch (err) {
504
+ // gh auth status exits 1 if not logged in, but still outputs info to stderr
505
+ const output = err instanceof Error ? (err as { stderr?: string }).stderr ?? '' : '';
506
+ if (output.includes('Logged in')) {
507
+ authenticated = true;
508
+ const match = output.match(/account (\S+)/i);
509
+ if (match) username = match[1];
510
+ }
511
+ }
512
+
513
+ // Check remote
514
+ let hasRemote = false;
515
+ let remoteUrl: string | undefined;
516
+ try {
517
+ const repoRoot = resolveRepoRoot(root, repo);
518
+ if (isGitRoot(repoRoot)) {
519
+ remoteUrl = run('git remote get-url origin', repoRoot) || undefined;
520
+ hasRemote = !!remoteUrl;
521
+ }
522
+ } catch {
523
+ // ignore
524
+ }
525
+
526
+ return { ghInstalled, authenticated, username, hasRemote, remoteUrl };
527
+ }
528
+
529
+ /**
530
+ * Create a GitHub repo, set remote, and push
531
+ */
532
+ export function githubCreateRepo(
533
+ root: string,
534
+ repoName: string,
535
+ visibility: 'private' | 'public' = 'private',
536
+ repo: RepoType = 'akb',
537
+ ): GitHubCreateResult {
538
+ const repoRoot = resolveRepoRoot(root, repo);
539
+
540
+ // Auto-init git if not a proper git root (e.g. fresh init, or nested inside parent repo)
541
+ if (!isGitRoot(repoRoot)) {
542
+ const initResult = gitInit(root, repo);
543
+ if (!initResult.ok) {
544
+ return { ok: false, message: initResult.message };
545
+ }
546
+ }
547
+
548
+ // Check gh + auth
549
+ const status = githubStatus(root, repo);
550
+ if (!status.ghInstalled) {
551
+ return { ok: false, message: 'GitHub CLI (gh) is not installed' };
552
+ }
553
+ if (!status.authenticated) {
554
+ return { ok: false, message: 'Not logged in to GitHub — run "gh auth login" first' };
555
+ }
556
+ if (status.hasRemote) {
557
+ return { ok: false, message: `Remote already configured: ${status.remoteUrl}` };
558
+ }
559
+
560
+ // Create repo + set remote + push
561
+ try {
562
+ const flag = visibility === 'public' ? '--public' : '--private';
563
+ const result = execSync(
564
+ `gh repo create "${repoName}" ${flag} --source=. --remote=origin --push`,
565
+ { cwd: repoRoot, encoding: 'utf-8', timeout: 30000, stdio: ['pipe', 'pipe', 'pipe'] },
566
+ ).trim();
567
+
568
+ // Extract repo URL from output
569
+ const urlMatch = result.match(/(https:\/\/github\.com\/\S+)/);
570
+ const repoUrl = urlMatch ? urlMatch[1] : undefined;
571
+ const remoteUrl = run('git remote get-url origin', repoRoot) || undefined;
572
+
573
+ return { ok: true, message: 'Repository created and pushed', repoUrl, remoteUrl };
574
+ } catch (err) {
575
+ const msg = err instanceof Error ? err.message : 'Failed to create repository';
576
+ // Common errors
577
+ if (msg.includes('already exists')) {
578
+ return { ok: false, message: 'A repository with this name already exists on GitHub' };
579
+ }
580
+ return { ok: false, message: msg };
581
+ }
582
+ }
583
+
584
+ /**
585
+ * Manually add a git remote
586
+ */
587
+ export function gitAddRemote(root: string, url: string, repo: RepoType = 'akb'): { ok: boolean; message: string } {
588
+ const repoRoot = resolveRepoRoot(root, repo);
589
+
590
+ if (!isGitRoot(repoRoot)) {
591
+ return { ok: false, message: 'Not a git repository' };
592
+ }
593
+
594
+ const existing = run('git remote get-url origin', repoRoot);
595
+ if (existing) {
596
+ return { ok: false, message: `Remote already configured: ${existing}` };
597
+ }
598
+
599
+ try {
600
+ runOrThrow(`git remote add origin "${url}"`, repoRoot);
601
+ // Try initial push
602
+ const branch = run('git rev-parse --abbrev-ref HEAD', repoRoot) || 'main';
603
+ try {
604
+ execSync(`git push -u origin ${branch}`, {
605
+ cwd: repoRoot, encoding: 'utf-8', timeout: 15000, stdio: ['pipe', 'pipe', 'pipe'],
606
+ });
607
+ return { ok: true, message: `Remote added and pushed to ${url}` };
608
+ } catch {
609
+ return { ok: true, message: `Remote added: ${url} (push failed — check credentials)` };
610
+ }
611
+ } catch (err) {
612
+ return { ok: false, message: err instanceof Error ? err.message : 'Failed to add remote' };
613
+ }
614
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Backward-compatibility re-export from execution-manager.
3
+ * All consumers should migrate to importing from execution-manager.ts directly.
4
+ */
5
+ export {
6
+ executionManager as jobManager,
7
+ executionManager,
8
+ type Execution as Job,
9
+ type Execution,
10
+ type StartExecutionParams as StartJobParams,
11
+ type StartExecutionParams,
12
+ type ExecStatus,
13
+ type ExecType,
14
+ canTransition,
15
+ messageStatusToRoleStatus,
16
+ } from './execution-manager.js';