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,309 @@
1
+ import { Router, Request, Response, NextFunction } from 'express';
2
+ import { readFile, listFiles, fileExists, COMPANY_ROOT } from '../services/file-reader.js';
3
+ import { extractBoldKeyValues } from '../services/markdown-parser.js';
4
+ import { ActivityStream } from '../services/activity-stream.js';
5
+ import path from 'node:path';
6
+ import fs from 'node:fs';
7
+ import { type MessageStatus, isMessageActive } from '../../../shared/types.js';
8
+
9
+ export const operationsRouter = Router();
10
+
11
+ // --- Standups ---
12
+ operationsRouter.get('/standups', (_req: Request, res: Response, next: NextFunction) => {
13
+ try {
14
+ const files = listFiles('.tycono/standup');
15
+ const standups = files
16
+ .filter(f => f.endsWith('.md'))
17
+ .map(f => {
18
+ const date = path.basename(f, '.md');
19
+ const content = readFile(`.tycono/standup/${f}`);
20
+ return { date, content };
21
+ })
22
+ .sort((a, b) => b.date.localeCompare(a.date));
23
+
24
+ res.json(standups);
25
+ } catch (err) {
26
+ next(err);
27
+ }
28
+ });
29
+
30
+ operationsRouter.get('/standups/:date', (req: Request, res: Response, next: NextFunction) => {
31
+ try {
32
+ const { date } = req.params;
33
+ const filePath = `.tycono/standup/${date}.md`;
34
+ if (!fileExists(filePath)) {
35
+ res.status(404).json({ error: `Standup not found: ${date}` });
36
+ return;
37
+ }
38
+ const content = readFile(filePath);
39
+ res.json({ date, content });
40
+ } catch (err) {
41
+ next(err);
42
+ }
43
+ });
44
+
45
+ // --- Waves (JSON-only) ---
46
+ operationsRouter.get('/waves', (_req: Request, res: Response, next: NextFunction) => {
47
+ try {
48
+ const files = listFiles('.tycono/waves', '*.json');
49
+ const waves = files
50
+ .map(f => {
51
+ const id = path.basename(f, '.json');
52
+ try {
53
+ const data = JSON.parse(readFile(`.tycono/waves/${f}`));
54
+ const roles = data.roles ?? [];
55
+ const hasRunning = roles.some((r: { status?: string }) => r.status && isMessageActive(r.status as MessageStatus));
56
+ return {
57
+ id,
58
+ timestamp: id,
59
+ directive: data.directive ?? '',
60
+ rolesCount: roles.length,
61
+ startedAt: data.startedAt ?? '',
62
+ ...(data.commit ? { commit: data.commit } : {}),
63
+ ...(hasRunning ? { hasRunning: true } : {}),
64
+ ...(data.sessionIds && data.sessionIds.length > 0 ? { sessionIds: data.sessionIds } : {}),
65
+ };
66
+ } catch {
67
+ return { id, timestamp: id, directive: '', rolesCount: 0, startedAt: '' };
68
+ }
69
+ })
70
+ .sort((a, b) => b.timestamp.localeCompare(a.timestamp));
71
+
72
+ res.json(waves);
73
+ } catch (err) {
74
+ next(err);
75
+ }
76
+ });
77
+
78
+ operationsRouter.get('/waves/:id', (req: Request, res: Response, next: NextFunction) => {
79
+ try {
80
+ const { id } = req.params;
81
+ const jsonPath = `.tycono/waves/${id}.json`;
82
+
83
+ if (!fileExists(jsonPath)) {
84
+ res.status(404).json({ error: `Wave not found: ${id}` });
85
+ return;
86
+ }
87
+
88
+ const data = JSON.parse(readFile(jsonPath));
89
+ res.json({ id, timestamp: id, replay: data });
90
+ } catch (err) {
91
+ next(err);
92
+ }
93
+ });
94
+
95
+ // PATCH /waves/:id — update wave metadata (e.g. commit info)
96
+ operationsRouter.patch('/waves/:id', (req: Request, res: Response, next: NextFunction) => {
97
+ try {
98
+ const { id } = req.params;
99
+ const jsonPath = `.tycono/waves/${id}.json`;
100
+
101
+ if (!fileExists(jsonPath)) {
102
+ res.status(404).json({ error: `Wave not found: ${id}` });
103
+ return;
104
+ }
105
+
106
+ const data = JSON.parse(readFile(jsonPath));
107
+ const { commitSha, commitMessage, committedAt } = req.body ?? {};
108
+ if (commitSha) {
109
+ data.commit = { sha: commitSha, message: commitMessage ?? '', committedAt: committedAt ?? new Date().toISOString() };
110
+ }
111
+ const absPath = path.resolve(COMPANY_ROOT, jsonPath);
112
+ fs.writeFileSync(absPath, JSON.stringify(data, null, 2), 'utf-8');
113
+ res.json({ ok: true });
114
+ } catch (err) {
115
+ next(err);
116
+ }
117
+ });
118
+
119
+ // --- Decisions ---
120
+ operationsRouter.get('/decisions', (_req: Request, res: Response, next: NextFunction) => {
121
+ try {
122
+ const files = listFiles('knowledge/decisions');
123
+ const decisions = files
124
+ .filter(f => f.endsWith('.md'))
125
+ .map(f => {
126
+ const id = path.basename(f, '.md');
127
+ const content = readFile(`knowledge/decisions/${f}`);
128
+ const firstLine = content.split('\n').find(l => l.startsWith('# '));
129
+ const title = firstLine ? firstLine.replace(/^#\s+/, '') : id;
130
+ const kv = extractBoldKeyValues(content);
131
+ const date = kv['날짜'] ?? kv['date'] ?? '';
132
+ return { id, title, date, content };
133
+ });
134
+
135
+ res.json(decisions);
136
+ } catch (err) {
137
+ next(err);
138
+ }
139
+ });
140
+
141
+ operationsRouter.put('/decisions/:id', (req: Request, res: Response, next: NextFunction) => {
142
+ try {
143
+ const { id } = req.params;
144
+ const { content } = req.body ?? {};
145
+ if (typeof content !== 'string') {
146
+ res.status(400).json({ error: 'content (string) is required' });
147
+ return;
148
+ }
149
+ const filePath = `knowledge/decisions/${id}.md`;
150
+ const absPath = path.resolve(COMPANY_ROOT, filePath);
151
+ // Ensure parent directory exists
152
+ fs.mkdirSync(path.dirname(absPath), { recursive: true });
153
+ fs.writeFileSync(absPath, content, 'utf-8');
154
+ // Re-parse for response
155
+ const firstLine = content.split('\n').find(l => l.startsWith('# '));
156
+ const title = firstLine ? firstLine.replace(/^#\s+/, '') : id;
157
+ const kv = extractBoldKeyValues(content);
158
+ const date = kv['날짜'] ?? kv['date'] ?? '';
159
+ res.json({ id, title, date, content });
160
+ } catch (err) {
161
+ next(err);
162
+ }
163
+ });
164
+
165
+ operationsRouter.delete('/decisions/:id', (req: Request, res: Response, next: NextFunction) => {
166
+ try {
167
+ const { id } = req.params;
168
+ const filePath = `knowledge/decisions/${id}.md`;
169
+ const absPath = path.resolve(COMPANY_ROOT, filePath);
170
+ if (!fs.existsSync(absPath)) {
171
+ res.status(404).json({ error: `Decision not found: ${id}` });
172
+ return;
173
+ }
174
+ fs.unlinkSync(absPath);
175
+ res.json({ ok: true });
176
+ } catch (err) {
177
+ next(err);
178
+ }
179
+ });
180
+
181
+ // --- Traces (AI-readable agent conversation debugging) ---
182
+
183
+ /**
184
+ * GET /api/ops/traces/:jobId — Dump full trace for a job
185
+ * Returns all events including full prompt/response for the job
186
+ * and all child jobs in the trace chain.
187
+ *
188
+ * Query params:
189
+ * ?chain=true — include all jobs in the same trace (default: true)
190
+ * ?type=trace — filter to trace:response events (use ?type=prompt for prompt:assembled)
191
+ */
192
+ operationsRouter.get('/traces/:jobId', (req: Request, res: Response, next: NextFunction) => {
193
+ try {
194
+ const jobId = String(req.params.jobId);
195
+ const includeChain = String(req.query.chain ?? 'true') !== 'false';
196
+ const typeFilter = String(req.query.type ?? '') || undefined;
197
+
198
+ // Read the target job's events
199
+ const events = ActivityStream.readAll(jobId);
200
+ if (events.length === 0) {
201
+ res.status(404).json({ error: `No activity stream found for job: ${jobId}` });
202
+ return;
203
+ }
204
+
205
+ // Extract traceId from the first event
206
+ const traceId = events[0]?.traceId ?? events.find(e => e.data?.traceId)?.data?.traceId as string ?? jobId;
207
+
208
+ if (!includeChain) {
209
+ const filtered = typeFilter
210
+ ? events.filter(e => e.type.startsWith(typeFilter))
211
+ : events;
212
+ res.json({ traceId, jobs: [{ jobId, events: filtered }] });
213
+ return;
214
+ }
215
+
216
+ // Find all jobs in the same trace
217
+ const allJobIds = ActivityStream.listAll();
218
+ const traceJobs: Array<{ jobId: string; roleId: string; events: typeof events }> = [];
219
+
220
+ for (const jid of allJobIds) {
221
+ const jobEvents = ActivityStream.readAll(jid);
222
+ const startEvent = jobEvents.find(e => e.type === 'msg:start' || (e.type as string) === 'job:start');
223
+ const jobTraceId = jobEvents[0]?.traceId ?? startEvent?.data?.traceId;
224
+
225
+ if (jobTraceId === traceId || jid === jobId) {
226
+ const filtered = typeFilter
227
+ ? jobEvents.filter(e => e.type.startsWith(typeFilter))
228
+ : jobEvents;
229
+ traceJobs.push({
230
+ jobId: jid,
231
+ roleId: startEvent?.roleId ?? 'unknown',
232
+ events: filtered,
233
+ });
234
+ }
235
+ }
236
+
237
+ // Sort by timestamp of first event
238
+ traceJobs.sort((a, b) => {
239
+ const aTs = a.events[0]?.ts ?? '';
240
+ const bTs = b.events[0]?.ts ?? '';
241
+ return aTs.localeCompare(bTs);
242
+ });
243
+
244
+ res.json({ traceId, jobCount: traceJobs.length, jobs: traceJobs });
245
+ } catch (err) {
246
+ next(err);
247
+ }
248
+ });
249
+
250
+ /**
251
+ * GET /api/ops/traces — List recent traces (grouped by traceId)
252
+ * Query params:
253
+ * ?limit=20 — max traces to return
254
+ * ?roleId=cto — filter by role
255
+ */
256
+ operationsRouter.get('/traces', (req: Request, res: Response, next: NextFunction) => {
257
+ try {
258
+ const limit = parseInt(String(req.query.limit ?? '20')) || 20;
259
+ const roleFilter = req.query.roleId ? String(req.query.roleId) : undefined;
260
+
261
+ const allJobIds = ActivityStream.listAll();
262
+ const traces = new Map<string, {
263
+ traceId: string;
264
+ startedAt: string;
265
+ rootRole: string;
266
+ rootTask: string;
267
+ jobCount: number;
268
+ status: string;
269
+ }>();
270
+
271
+ for (const jid of allJobIds) {
272
+ const events = ActivityStream.readAll(jid);
273
+ const startEvent = events.find(e => e.type === 'msg:start' || (e.type as string) === 'job:start');
274
+ if (!startEvent) continue;
275
+
276
+ const traceId = events[0]?.traceId ?? startEvent?.data?.traceId as string ?? jid;
277
+ if (roleFilter && startEvent.roleId !== roleFilter) continue;
278
+
279
+ if (!traces.has(traceId)) {
280
+ const doneEvent = events.find(e => e.type === 'msg:done' || (e.type as string) === 'job:done');
281
+ const errorEvent = events.find(e => e.type === 'msg:error' || (e.type as string) === 'job:error');
282
+ const awaitingEvent = events.find(e => e.type === 'msg:awaiting_input' || (e.type as string) === 'job:awaiting_input');
283
+ const status = awaitingEvent ? 'awaiting_input'
284
+ : doneEvent ? 'done'
285
+ : errorEvent ? 'error'
286
+ : 'running';
287
+
288
+ traces.set(traceId, {
289
+ traceId,
290
+ startedAt: startEvent.ts,
291
+ rootRole: startEvent.roleId,
292
+ rootTask: (startEvent.data.task as string ?? '').slice(0, 200),
293
+ jobCount: 1,
294
+ status,
295
+ });
296
+ } else {
297
+ traces.get(traceId)!.jobCount++;
298
+ }
299
+ }
300
+
301
+ const sorted = [...traces.values()]
302
+ .sort((a, b) => b.startedAt.localeCompare(a.startedAt))
303
+ .slice(0, limit);
304
+
305
+ res.json(sorted);
306
+ } catch (err) {
307
+ next(err);
308
+ }
309
+ });
@@ -0,0 +1,63 @@
1
+ import crypto from 'node:crypto';
2
+ import { Router, Request, Response, NextFunction } from 'express';
3
+ import { COMPANY_ROOT } from '../services/file-reader.js';
4
+ import { readPreferences, writePreferences, mergePreferences } from '../services/preferences.js';
5
+
6
+ export const preferencesRouter = Router();
7
+
8
+ // GET /api/preferences
9
+ preferencesRouter.get('/', (_req: Request, res: Response, next: NextFunction) => {
10
+ try {
11
+ res.json(readPreferences(COMPANY_ROOT));
12
+ } catch (err) {
13
+ next(err);
14
+ }
15
+ });
16
+
17
+ // PUT /api/preferences — full overwrite
18
+ preferencesRouter.put('/', (req: Request, res: Response, next: NextFunction) => {
19
+ try {
20
+ const prefs = req.body;
21
+ if (!prefs || typeof prefs !== 'object') {
22
+ res.status(400).json({ error: 'Invalid preferences body' });
23
+ return;
24
+ }
25
+ const existing = readPreferences(COMPANY_ROOT);
26
+ writePreferences(COMPANY_ROOT, {
27
+ instanceId: existing.instanceId, // preserve — never overwrite from client
28
+ appearances: prefs.appearances ?? {},
29
+ theme: prefs.theme ?? 'default',
30
+ });
31
+ res.json({ ok: true, ...readPreferences(COMPANY_ROOT) });
32
+ } catch (err) {
33
+ next(err);
34
+ }
35
+ });
36
+
37
+ // POST /api/preferences/regenerate-token — regenerate instanceId
38
+ preferencesRouter.post('/regenerate-token', (_req: Request, res: Response, next: NextFunction) => {
39
+ try {
40
+ const current = readPreferences(COMPANY_ROOT);
41
+ const oldId = current.instanceId;
42
+ current.instanceId = crypto.randomUUID();
43
+ writePreferences(COMPANY_ROOT, current);
44
+ res.json({ ok: true, oldInstanceId: oldId, newInstanceId: current.instanceId });
45
+ } catch (err) {
46
+ next(err);
47
+ }
48
+ });
49
+
50
+ // PATCH /api/preferences — partial merge
51
+ preferencesRouter.patch('/', (req: Request, res: Response, next: NextFunction) => {
52
+ try {
53
+ const partial = req.body;
54
+ if (!partial || typeof partial !== 'object') {
55
+ res.status(400).json({ error: 'Invalid preferences body' });
56
+ return;
57
+ }
58
+ const merged = mergePreferences(COMPANY_ROOT, partial);
59
+ res.json({ ok: true, ...merged });
60
+ } catch (err) {
61
+ next(err);
62
+ }
63
+ });
@@ -0,0 +1,123 @@
1
+ /**
2
+ * presets.ts — Preset API routes
3
+ *
4
+ * GET /api/presets — list all preset summaries
5
+ * GET /api/presets/:id — get full preset detail
6
+ * POST /api/presets/install — install preset from data
7
+ * DELETE /api/presets/:id — remove installed preset
8
+ */
9
+ import { Router } from 'express';
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import YAML from 'yaml';
13
+ import { COMPANY_ROOT } from '../services/file-reader.js';
14
+ import { getPresetSummaries, getPresetById, loadPresets } from '../services/preset-loader.js';
15
+
16
+ export const presetsRouter = Router();
17
+
18
+ /** GET /api/presets — list preset summaries */
19
+ presetsRouter.get('/', (_req, res) => {
20
+ try {
21
+ const summaries = getPresetSummaries(COMPANY_ROOT);
22
+ res.json(summaries);
23
+ } catch (err) {
24
+ res.status(500).json({ error: 'Failed to load presets' });
25
+ }
26
+ });
27
+
28
+ /** GET /api/presets/:id — get full preset detail */
29
+ presetsRouter.get('/:id', (req, res) => {
30
+ try {
31
+ const preset = getPresetById(COMPANY_ROOT, req.params.id);
32
+ if (!preset) {
33
+ res.status(404).json({ error: `Preset not found: ${req.params.id}` });
34
+ return;
35
+ }
36
+ res.json(preset.definition);
37
+ } catch (err) {
38
+ res.status(500).json({ error: 'Failed to load preset' });
39
+ }
40
+ });
41
+
42
+ /** POST /api/presets/install — install a preset from provided data */
43
+ presetsRouter.post('/install', (req, res) => {
44
+ try {
45
+ const { id, preset } = req.body as { id: string; preset: Record<string, unknown> };
46
+ if (!id || !preset) {
47
+ res.status(400).json({ error: 'id and preset are required' });
48
+ return;
49
+ }
50
+
51
+ // Validate preset has required fields
52
+ if (!preset.name || !preset.roles || !Array.isArray(preset.roles)) {
53
+ res.status(400).json({ error: 'preset must have name and roles array' });
54
+ return;
55
+ }
56
+
57
+ // Check for conflict with existing preset
58
+ const existing = getPresetById(COMPANY_ROOT, id);
59
+ if (existing && !existing.isDefault) {
60
+ res.status(409).json({ error: `Preset already installed: ${id}` });
61
+ return;
62
+ }
63
+
64
+ // Create preset directory and write preset.yaml
65
+ const presetDir = path.join(COMPANY_ROOT, 'knowledge', 'presets', id);
66
+ fs.mkdirSync(presetDir, { recursive: true });
67
+
68
+ // Write preset.yaml
69
+ const yamlContent = YAML.stringify(preset);
70
+ fs.writeFileSync(path.join(presetDir, 'preset.yaml'), yamlContent);
71
+
72
+ // Create subdirectories for roles/knowledge/skills
73
+ fs.mkdirSync(path.join(presetDir, 'roles'), { recursive: true });
74
+ fs.mkdirSync(path.join(presetDir, 'knowledge'), { recursive: true });
75
+ fs.mkdirSync(path.join(presetDir, 'skills'), { recursive: true });
76
+
77
+ // Write knowledge docs if provided
78
+ const knowledge = req.body.knowledge as Array<{ filename: string; content: string }> | undefined;
79
+ if (knowledge) {
80
+ for (const doc of knowledge) {
81
+ fs.writeFileSync(path.join(presetDir, 'knowledge', doc.filename), doc.content);
82
+ }
83
+ }
84
+
85
+ // Write role yamls if provided
86
+ const roleDefinitions = req.body.roleDefinitions as Array<{ id: string; yaml: string }> | undefined;
87
+ if (roleDefinitions) {
88
+ for (const role of roleDefinitions) {
89
+ const roleDir = path.join(presetDir, 'roles', role.id);
90
+ fs.mkdirSync(roleDir, { recursive: true });
91
+ fs.writeFileSync(path.join(roleDir, 'role.yaml'), role.yaml);
92
+ }
93
+ }
94
+
95
+ res.json({ ok: true, id, path: `knowledge/presets/${id}` });
96
+ } catch (err) {
97
+ res.status(500).json({ error: `Install failed: ${err instanceof Error ? err.message : 'unknown'}` });
98
+ }
99
+ });
100
+
101
+ /** DELETE /api/presets/:id — remove installed preset */
102
+ presetsRouter.delete('/:id', (req, res) => {
103
+ try {
104
+ const { id } = req.params;
105
+ if (id === 'default' || id === '_default') {
106
+ res.status(400).json({ error: 'Cannot remove default preset' });
107
+ return;
108
+ }
109
+
110
+ const presetDir = path.join(COMPANY_ROOT, 'knowledge', 'presets', id);
111
+ if (!fs.existsSync(presetDir)) {
112
+ res.status(404).json({ error: `Preset not found: ${id}` });
113
+ return;
114
+ }
115
+
116
+ // Remove preset directory recursively
117
+ fs.rmSync(presetDir, { recursive: true, force: true });
118
+
119
+ res.json({ ok: true, id });
120
+ } catch (err) {
121
+ res.status(500).json({ error: `Remove failed: ${err instanceof Error ? err.message : 'unknown'}` });
122
+ }
123
+ });
@@ -0,0 +1,82 @@
1
+ import { Router, Request, Response, NextFunction } from 'express';
2
+ import { readFile, fileExists } from '../services/file-reader.js';
3
+ import { parseMarkdownTable } from '../services/markdown-parser.js';
4
+
5
+ export const projectsRouter = Router();
6
+
7
+ // GET /api/projects — 프로젝트 목록
8
+ projectsRouter.get('/', (_req: Request, res: Response, next: NextFunction) => {
9
+ try {
10
+ const content = readFile('knowledge/projects/projects.md');
11
+ const rows = parseMarkdownTable(content);
12
+
13
+ const projects = rows.map(row => {
14
+ const name = row.project ?? '';
15
+ const id = name.toLowerCase().replace(/\s+/g, '-');
16
+ return {
17
+ id,
18
+ name,
19
+ status: row.status ?? '',
20
+ created: row.created ?? '',
21
+ };
22
+ });
23
+
24
+ res.json(projects);
25
+ } catch (err) {
26
+ next(err);
27
+ }
28
+ });
29
+
30
+ // GET /api/projects/:id — 프로젝트 상세
31
+ projectsRouter.get('/:id', (req: Request, res: Response, next: NextFunction) => {
32
+ try {
33
+ const { id } = req.params;
34
+
35
+ // 기본 정보
36
+ const listContent = readFile('knowledge/projects/projects.md');
37
+ const rows = parseMarkdownTable(listContent);
38
+ const projectRow = rows.find(r => {
39
+ const name = r.project ?? '';
40
+ return name.toLowerCase().replace(/\s+/g, '-') === id;
41
+ });
42
+
43
+ if (!projectRow) {
44
+ res.status(404).json({ error: `Project not found: ${id}` });
45
+ return;
46
+ }
47
+
48
+ const name = projectRow.project ?? '';
49
+ const project: Record<string, unknown> = {
50
+ id,
51
+ name,
52
+ status: projectRow.status ?? '',
53
+ created: projectRow.created ?? '',
54
+ prd: '',
55
+ tasks: [],
56
+ };
57
+
58
+ // PRD 읽기
59
+ const prdPath = `knowledge/projects/${id}/prd.md`;
60
+ if (fileExists(prdPath)) {
61
+ project.prd = readFile(prdPath);
62
+ }
63
+
64
+ // Tasks 읽기
65
+ const tasksPath = `knowledge/projects/${id}/tasks.md`;
66
+ if (fileExists(tasksPath)) {
67
+ const tasksContent = readFile(tasksPath);
68
+ const taskRows = parseMarkdownTable(tasksContent);
69
+ project.tasks = taskRows.map(row => ({
70
+ id: row.id ?? '',
71
+ title: row.task ?? row.title ?? '',
72
+ role: row.role ?? '',
73
+ status: row.status ?? '',
74
+ description: row.설명 ?? row.description ?? '',
75
+ }));
76
+ }
77
+
78
+ res.json(project);
79
+ } catch (err) {
80
+ next(err);
81
+ }
82
+ });
@@ -0,0 +1,41 @@
1
+ import { Router, Request, Response, NextFunction } from 'express';
2
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { COMPANY_ROOT } from '../services/file-reader.js';
5
+
6
+ export const questsRouter = Router();
7
+
8
+ const QUEST_FILE = () => join(COMPANY_ROOT, '.tycono', 'quest-progress.json');
9
+
10
+ function readProgress(): Record<string, unknown> {
11
+ try {
12
+ return JSON.parse(readFileSync(QUEST_FILE(), 'utf-8'));
13
+ } catch {
14
+ return { completedQuests: [], activeChapter: 1, sideQuestsCompleted: [] };
15
+ }
16
+ }
17
+
18
+ function writeProgress(data: Record<string, unknown>) {
19
+ mkdirSync(join(COMPANY_ROOT, '.tycono'), { recursive: true });
20
+ writeFileSync(QUEST_FILE(), JSON.stringify(data, null, 2));
21
+ }
22
+
23
+ // GET /api/quests/progress
24
+ questsRouter.get('/progress', (_req: Request, res: Response, next: NextFunction) => {
25
+ try {
26
+ res.json(readProgress());
27
+ } catch (err) { next(err); }
28
+ });
29
+
30
+ // PUT /api/quests/progress
31
+ questsRouter.put('/progress', (req: Request, res: Response, next: NextFunction) => {
32
+ try {
33
+ const body = req.body;
34
+ if (!body || typeof body !== 'object') {
35
+ res.status(400).json({ error: 'Invalid body' });
36
+ return;
37
+ }
38
+ writeProgress(body);
39
+ res.json({ ok: true, ...body });
40
+ } catch (err) { next(err); }
41
+ });