tycono 0.3.13-beta.7 → 0.3.14-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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tycono",
3
- "version": "0.3.13-beta.7",
3
+ "version": "0.3.14-beta.0",
4
4
  "description": "Build an AI company. Watch them work.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,6 +17,7 @@ import { engineRouter } from './routes/engine.js';
17
17
  import { sessionsRouter } from './routes/sessions.js';
18
18
  import { setupRouter } from './routes/setup.js';
19
19
  import { skillsRouter } from './routes/skills.js';
20
+ import { presetsRouter } from './routes/presets.js';
20
21
 
21
22
  export function createApp() {
22
23
  const app = express();
@@ -53,6 +54,7 @@ export function createApp() {
53
54
  app.use('/api/engine', engineRouter);
54
55
  app.use('/api/sessions', sessionsRouter);
55
56
  app.use('/api/skills', skillsRouter);
57
+ app.use('/api/presets', presetsRouter);
56
58
 
57
59
  app.get('/api/health', (_req, res) => {
58
60
  res.json({ status: 'ok', companyRoot: COMPANY_ROOT });
@@ -85,7 +85,7 @@ interface RawRoleYaml {
85
85
 
86
86
  /* ─── Build ──────────────────────────────────── */
87
87
 
88
- export function buildOrgTree(companyRoot: string): OrgTree {
88
+ export function buildOrgTree(companyRoot: string, presetId?: string): OrgTree {
89
89
  const rolesDir = path.join(companyRoot, 'roles');
90
90
  const tree: OrgTree = { root: 'ceo', nodes: new Map() };
91
91
 
@@ -102,53 +102,68 @@ export function buildOrgTree(companyRoot: string): OrgTree {
102
102
  reports: { daily: '', weekly: '' },
103
103
  });
104
104
 
105
- if (!fs.existsSync(rolesDir)) return tree;
106
-
107
- // Read all role.yaml files
108
- const entries = fs.readdirSync(rolesDir, { withFileTypes: true });
109
- for (const entry of entries) {
110
- if (!entry.isDirectory()) continue;
111
- const yamlPath = path.join(rolesDir, entry.name, 'role.yaml');
112
- if (!fs.existsSync(yamlPath)) continue;
113
-
114
- try {
115
- const raw = YAML.parse(fs.readFileSync(yamlPath, 'utf-8')) as RawRoleYaml;
116
- const node: OrgNode = {
117
- id: raw.id || entry.name,
118
- name: raw.name || entry.name,
119
- level: (raw.level as OrgNode['level']) || 'member',
120
- reportsTo: (raw.reports_to || 'ceo').toLowerCase(),
121
- children: [],
122
- persona: raw.persona || '',
123
- authority: {
124
- autonomous: raw.authority?.autonomous ?? [],
125
- needsApproval: raw.authority?.needs_approval ?? [],
126
- },
127
- knowledge: {
128
- reads: raw.knowledge?.reads ?? [],
129
- writes: raw.knowledge?.writes ?? [],
130
- },
131
- reports: {
132
- daily: raw.reports?.daily ?? '',
133
- weekly: raw.reports?.weekly ?? '',
134
- },
135
- skills: raw.skills,
136
- model: raw.model,
137
- source: raw.source ? {
138
- id: raw.source.id || '',
139
- sync: (raw.source.sync as RoleSource['sync']) || 'manual',
140
- forked_at: raw.source.forked_at,
141
- upstream_version: raw.source.upstream_version,
142
- } : undefined,
143
- heartbeat: raw.heartbeat ? {
144
- enabled: raw.heartbeat.enabled ?? false,
145
- intervalSec: raw.heartbeat.intervalSec ?? 120,
146
- maxTicks: raw.heartbeat.maxTicks ?? 60,
147
- } : undefined,
148
- };
149
- tree.nodes.set(node.id, node);
150
- } catch {
151
- // Skip malformed YAML
105
+ // Collect role directories to scan: base roles/ + preset roles/
106
+ const roleDirs: string[] = [];
107
+ if (fs.existsSync(rolesDir)) roleDirs.push(rolesDir);
108
+
109
+ // If preset specified, also scan preset's roles directory
110
+ if (presetId && presetId !== 'default') {
111
+ const presetRolesDir = path.join(companyRoot, 'company', 'presets', presetId, 'roles');
112
+ if (fs.existsSync(presetRolesDir)) roleDirs.push(presetRolesDir);
113
+ }
114
+
115
+ // Read all role.yaml files from all role directories
116
+ for (const dir of roleDirs) {
117
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
118
+ for (const entry of entries) {
119
+ if (!entry.isDirectory()) continue;
120
+ const yamlPath = path.join(dir, entry.name, 'role.yaml');
121
+ if (!fs.existsSync(yamlPath)) continue;
122
+
123
+ try {
124
+ const raw = YAML.parse(fs.readFileSync(yamlPath, 'utf-8')) as RawRoleYaml;
125
+ const nodeId = raw.id || entry.name;
126
+
127
+ // Skip if already loaded (base roles take precedence over preset roles)
128
+ if (tree.nodes.has(nodeId)) continue;
129
+
130
+ const node: OrgNode = {
131
+ id: nodeId,
132
+ name: raw.name || entry.name,
133
+ level: (raw.level as OrgNode['level']) || 'member',
134
+ reportsTo: (raw.reports_to || 'ceo').toLowerCase(),
135
+ children: [],
136
+ persona: raw.persona || '',
137
+ authority: {
138
+ autonomous: raw.authority?.autonomous ?? [],
139
+ needsApproval: raw.authority?.needs_approval ?? [],
140
+ },
141
+ knowledge: {
142
+ reads: raw.knowledge?.reads ?? [],
143
+ writes: raw.knowledge?.writes ?? [],
144
+ },
145
+ reports: {
146
+ daily: raw.reports?.daily ?? '',
147
+ weekly: raw.reports?.weekly ?? '',
148
+ },
149
+ skills: raw.skills,
150
+ model: raw.model,
151
+ source: raw.source ? {
152
+ id: raw.source.id || '',
153
+ sync: (raw.source.sync as RoleSource['sync']) || 'manual',
154
+ forked_at: raw.source.forked_at,
155
+ upstream_version: raw.source.upstream_version,
156
+ } : undefined,
157
+ heartbeat: raw.heartbeat ? {
158
+ enabled: raw.heartbeat.enabled ?? false,
159
+ intervalSec: raw.heartbeat.intervalSec ?? 120,
160
+ maxTicks: raw.heartbeat.maxTicks ?? 60,
161
+ } : undefined,
162
+ };
163
+ tree.nodes.set(node.id, node);
164
+ } catch {
165
+ // Skip malformed YAML
166
+ }
152
167
  }
153
168
  }
154
169
 
@@ -231,8 +246,8 @@ export function canConsult(tree: OrgTree, source: string, target: string): boole
231
246
  }
232
247
 
233
248
  /** Refresh tree (re-read all role.yaml files) */
234
- export function refreshOrgTree(companyRoot: string): OrgTree {
235
- return buildOrgTree(companyRoot);
249
+ export function refreshOrgTree(companyRoot: string, presetId?: string): OrgTree {
250
+ return buildOrgTree(companyRoot, presetId);
236
251
  }
237
252
 
238
253
  /** Get a human-readable org chart string for context injection */
@@ -223,6 +223,7 @@ function handleStartJob(body: Record<string, unknown>, res: ServerResponse): voi
223
223
 
224
224
  const targetRoles = body.targetRoles as string[] | undefined;
225
225
  const continuous = body.continuous === true;
226
+ const preset = body.preset as string | undefined;
226
227
 
227
228
  // Always use supervisor mode — CEO supervises C-Levels who supervise members
228
229
  {
@@ -231,6 +232,7 @@ function handleStartJob(body: Record<string, unknown>, res: ServerResponse): voi
231
232
  actualDirective,
232
233
  targetRoles && targetRoles.length > 0 ? targetRoles : undefined,
233
234
  continuous,
235
+ preset,
234
236
  );
235
237
 
236
238
  if (state.status === 'error') {
@@ -0,0 +1,35 @@
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
+ */
7
+ import { Router } from 'express';
8
+ import { COMPANY_ROOT } from '../services/file-reader.js';
9
+ import { getPresetSummaries, getPresetById } from '../services/preset-loader.js';
10
+
11
+ export const presetsRouter = Router();
12
+
13
+ /** GET /api/presets — list preset summaries for TUI */
14
+ presetsRouter.get('/', (_req, res) => {
15
+ try {
16
+ const summaries = getPresetSummaries(COMPANY_ROOT);
17
+ res.json(summaries);
18
+ } catch (err) {
19
+ res.status(500).json({ error: 'Failed to load presets' });
20
+ }
21
+ });
22
+
23
+ /** GET /api/presets/:id — get full preset detail */
24
+ presetsRouter.get('/:id', (req, res) => {
25
+ try {
26
+ const preset = getPresetById(COMPANY_ROOT, req.params.id);
27
+ if (!preset) {
28
+ res.status(404).json({ error: `Preset not found: ${req.params.id}` });
29
+ return;
30
+ }
31
+ res.json(preset.definition);
32
+ } catch (err) {
33
+ res.status(500).json({ error: 'Failed to load preset' });
34
+ }
35
+ });
@@ -0,0 +1,149 @@
1
+ /**
2
+ * preset-loader.ts — Load presets from company/presets/
3
+ *
4
+ * Scans company/presets/ for:
5
+ * - _default.yaml (auto-generated from existing roles/)
6
+ * - {name}/preset.yaml (installed presets with roles/skills/knowledge)
7
+ *
8
+ * Returns PresetSummary[] for TUI display and full LoadedPreset for wave creation.
9
+ */
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import YAML from 'yaml';
13
+ import type { PresetDefinition, LoadedPreset, PresetSummary } from '../../../shared/types.js';
14
+
15
+ const PRESETS_DIR = 'company/presets';
16
+ const DEFAULT_PRESET_FILE = '_default.yaml';
17
+
18
+ /**
19
+ * Build a default preset definition from existing roles/ directory.
20
+ * This is generated on-the-fly — no need to persist _default.yaml.
21
+ */
22
+ function buildDefaultPreset(companyRoot: string): LoadedPreset {
23
+ const rolesDir = path.join(companyRoot, 'roles');
24
+ const roles: string[] = [];
25
+
26
+ if (fs.existsSync(rolesDir)) {
27
+ const entries = fs.readdirSync(rolesDir, { withFileTypes: true });
28
+ for (const entry of entries) {
29
+ if (!entry.isDirectory()) continue;
30
+ const yamlPath = path.join(rolesDir, entry.name, 'role.yaml');
31
+ if (fs.existsSync(yamlPath)) {
32
+ roles.push(entry.name);
33
+ }
34
+ }
35
+ }
36
+
37
+ return {
38
+ definition: {
39
+ spec: 'preset/v1',
40
+ id: 'default',
41
+ name: 'Default Team',
42
+ tagline: 'Your current team',
43
+ version: '1.0.0',
44
+ roles,
45
+ },
46
+ path: null,
47
+ isDefault: true,
48
+ };
49
+ }
50
+
51
+ /**
52
+ * Load a single preset from a directory containing preset.yaml.
53
+ */
54
+ function loadPresetFromDir(presetDir: string): LoadedPreset | null {
55
+ const yamlPath = path.join(presetDir, 'preset.yaml');
56
+ if (!fs.existsSync(yamlPath)) return null;
57
+
58
+ try {
59
+ const raw = YAML.parse(fs.readFileSync(yamlPath, 'utf-8')) as PresetDefinition;
60
+ if (!raw.id || !raw.name || !Array.isArray(raw.roles)) return null;
61
+
62
+ return {
63
+ definition: {
64
+ spec: raw.spec || 'preset/v1',
65
+ id: raw.id,
66
+ name: raw.name,
67
+ tagline: raw.tagline,
68
+ version: raw.version || '1.0.0',
69
+ description: raw.description,
70
+ author: raw.author,
71
+ category: raw.category,
72
+ industry: raw.industry,
73
+ stage: raw.stage,
74
+ use_case: raw.use_case,
75
+ roles: raw.roles,
76
+ knowledge_docs: raw.knowledge_docs,
77
+ skills_count: raw.skills_count,
78
+ pricing: raw.pricing,
79
+ tags: raw.tags,
80
+ languages: raw.languages,
81
+ stats: raw.stats,
82
+ wave_scoped: raw.wave_scoped,
83
+ },
84
+ path: presetDir,
85
+ isDefault: false,
86
+ };
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Load all presets from company/presets/ + auto-generated default.
94
+ * Returns [default, ...installed] — default is always first.
95
+ */
96
+ export function loadPresets(companyRoot: string): LoadedPreset[] {
97
+ const presets: LoadedPreset[] = [];
98
+
99
+ // 1. Default preset (always present)
100
+ const defaultPreset = buildDefaultPreset(companyRoot);
101
+
102
+ // Check if _default.yaml exists with overrides
103
+ const defaultYamlPath = path.join(companyRoot, PRESETS_DIR, DEFAULT_PRESET_FILE);
104
+ if (fs.existsSync(defaultYamlPath)) {
105
+ try {
106
+ const raw = YAML.parse(fs.readFileSync(defaultYamlPath, 'utf-8')) as Partial<PresetDefinition>;
107
+ if (raw.name) defaultPreset.definition.name = raw.name;
108
+ if (raw.tagline) defaultPreset.definition.tagline = raw.tagline;
109
+ if (raw.description) defaultPreset.definition.description = raw.description;
110
+ } catch { /* ignore malformed _default.yaml */ }
111
+ }
112
+
113
+ presets.push(defaultPreset);
114
+
115
+ // 2. Installed presets from company/presets/{name}/preset.yaml
116
+ const presetsDir = path.join(companyRoot, PRESETS_DIR);
117
+ if (fs.existsSync(presetsDir)) {
118
+ const entries = fs.readdirSync(presetsDir, { withFileTypes: true });
119
+ for (const entry of entries) {
120
+ if (!entry.isDirectory()) continue;
121
+ const preset = loadPresetFromDir(path.join(presetsDir, entry.name));
122
+ if (preset) presets.push(preset);
123
+ }
124
+ }
125
+
126
+ return presets;
127
+ }
128
+
129
+ /**
130
+ * Get preset summaries for TUI display.
131
+ */
132
+ export function getPresetSummaries(companyRoot: string): PresetSummary[] {
133
+ return loadPresets(companyRoot).map(p => ({
134
+ id: p.definition.id,
135
+ name: p.definition.name,
136
+ description: p.definition.description ?? p.definition.tagline,
137
+ rolesCount: p.definition.roles.length,
138
+ roles: p.definition.roles,
139
+ isDefault: p.isDefault,
140
+ }));
141
+ }
142
+
143
+ /**
144
+ * Find a specific preset by ID.
145
+ */
146
+ export function getPresetById(companyRoot: string, presetId: string): LoadedPreset | null {
147
+ const presets = loadPresets(companyRoot);
148
+ return presets.find(p => p.definition.id === presetId) ?? null;
149
+ }
@@ -319,7 +319,7 @@ export function scaffold(config: ScaffoldConfig): string[] {
319
319
  'operations/decisions', 'operations/activity-streams',
320
320
  'operations/sessions', 'operations/cost',
321
321
  'knowledge', 'methodologies', '.claude/skills',
322
- '.claude/skills/_shared', '.tycono',
322
+ '.claude/skills/_shared', '.tycono', 'company/presets',
323
323
  ];
324
324
  for (const dir of dirs) {
325
325
  fs.mkdirSync(path.join(root, dir), { recursive: true });
@@ -28,6 +28,7 @@ interface SupervisorState {
28
28
  directive: string;
29
29
  targetRoles?: string[];
30
30
  continuous: boolean;
31
+ preset?: string;
31
32
  supervisorSessionId: string | null;
32
33
  executionId: string | null;
33
34
  status: 'starting' | 'running' | 'restarting' | 'stopped' | 'error';
@@ -66,7 +67,7 @@ class SupervisorHeartbeat {
66
67
  * This creates a supervisor session and starts an execution.
67
68
  * If the execution dies, it auto-restarts (heartbeat).
68
69
  */
69
- start(waveId: string, directive: string, targetRoles?: string[], continuous = false): SupervisorState {
70
+ start(waveId: string, directive: string, targetRoles?: string[], continuous = false, preset?: string): SupervisorState {
70
71
  // Check if supervisor already running for this wave
71
72
  const existing = this.supervisors.get(waveId);
72
73
  if (existing && (existing.status === 'running' || existing.status === 'starting')) {
@@ -79,6 +80,7 @@ class SupervisorHeartbeat {
79
80
  directive,
80
81
  targetRoles,
81
82
  continuous,
83
+ preset,
82
84
  supervisorSessionId: null,
83
85
  executionId: null,
84
86
  status: 'starting',
@@ -100,7 +102,7 @@ class SupervisorHeartbeat {
100
102
  }
101
103
 
102
104
  // Save wave file immediately so directive persists across restarts
103
- this.saveWaveFile(waveId, directive);
105
+ this.saveWaveFile(waveId, directive, preset);
104
106
 
105
107
  this.spawnSupervisor(state);
106
108
  return state;
@@ -110,20 +112,22 @@ class SupervisorHeartbeat {
110
112
  * Save wave file immediately so directive persists across restarts.
111
113
  * saveCompletedWave() adds session/role details on completion.
112
114
  */
113
- private saveWaveFile(waveId: string, directive: string): void {
115
+ private saveWaveFile(waveId: string, directive: string, preset?: string): void {
114
116
  try {
115
117
  const wavesDir = path.join(COMPANY_ROOT, 'operations', 'waves');
116
118
  if (!fs.existsSync(wavesDir)) fs.mkdirSync(wavesDir, { recursive: true });
117
119
  const wavePath = path.join(wavesDir, `${waveId}.json`);
118
120
  if (!fs.existsSync(wavePath)) {
119
- fs.writeFileSync(wavePath, JSON.stringify({
121
+ const waveData: Record<string, unknown> = {
120
122
  id: waveId,
121
123
  waveId,
122
124
  directive,
123
125
  startedAt: new Date().toISOString(),
124
126
  sessionIds: [],
125
127
  roles: [],
126
- }, null, 2));
128
+ };
129
+ if (preset) waveData.preset = preset;
130
+ fs.writeFileSync(wavePath, JSON.stringify(waveData, null, 2));
127
131
  console.log(`[Supervisor] Wave file created: ${wavePath}`);
128
132
  }
129
133
  } catch (err) {
@@ -510,7 +514,7 @@ Do NOT dispatch anyone. Do NOT create new files. Just answer concisely.`;
510
514
  /* ─── Internal: Spawn / Restart ────────────── */
511
515
 
512
516
  private spawnSupervisor(state: SupervisorState): void {
513
- const orgTree = buildOrgTree(COMPANY_ROOT);
517
+ const orgTree = buildOrgTree(COMPANY_ROOT, state.preset);
514
518
  let cLevelRoles = getSubordinates(orgTree, 'ceo');
515
519
 
516
520
  if (state.targetRoles && state.targetRoles.length > 0) {
@@ -304,7 +304,16 @@ export function saveCompletedWave(waveId: string, directive: string): { ok: bool
304
304
  }
305
305
  }
306
306
 
307
- const waveJson = {
307
+ // Preserve preset field from existing wave file
308
+ let existingPreset: string | undefined;
309
+ if (existing) {
310
+ try {
311
+ const existingData = JSON.parse(fs.readFileSync(existing, 'utf-8'));
312
+ existingPreset = existingData.preset;
313
+ } catch { /* ignore */ }
314
+ }
315
+
316
+ const waveJson: Record<string, unknown> = {
308
317
  id: baseName,
309
318
  directive,
310
319
  startedAt: startedAt.toISOString(),
@@ -313,6 +322,7 @@ export function saveCompletedWave(waveId: string, directive: string): { ok: bool
313
322
  waveId,
314
323
  sessionIds: allSessionIds,
315
324
  };
325
+ if (existingPreset) waveJson.preset = existingPreset;
316
326
  fs.writeFileSync(jsonPath, JSON.stringify(waveJson, null, 2), 'utf-8');
317
327
 
318
328
  const relativePath = `operations/waves/${baseName}.json`;
@@ -144,3 +144,81 @@ export function eventTypeToMessageStatus(eventType: ActivityEventType): MessageS
144
144
 
145
145
  /** TeamStatus — Role별 현재 상태 + 작업 내용 (context-assembler, runner에서 공유) */
146
146
  export type TeamStatus = Record<string, { status: RoleStatus; task?: string }>;
147
+
148
+ /* ═══════════════════════════════════════════════
149
+ * Preset — Wave-scoped team configuration
150
+ * ═══════════════════════════════════════════════ */
151
+
152
+ /** preset.yaml 스키마 */
153
+ export interface PresetDefinition {
154
+ /** Spec version (e.g. "preset/v1") */
155
+ spec: string;
156
+ /** Unique identifier (directory name) */
157
+ id: string;
158
+ /** Display name */
159
+ name: string;
160
+ /** Short tagline */
161
+ tagline?: string;
162
+ /** Version string */
163
+ version: string;
164
+ /** Full description */
165
+ description?: string;
166
+ /** Author info */
167
+ author?: {
168
+ id: string;
169
+ name: string;
170
+ verified?: boolean;
171
+ };
172
+ /** Category / classification */
173
+ category?: string;
174
+ industry?: string;
175
+ stage?: string;
176
+ use_case?: string[];
177
+ /** Role IDs included in this preset */
178
+ roles: string[];
179
+ /** Counts */
180
+ knowledge_docs?: number;
181
+ skills_count?: number;
182
+ /** Pricing */
183
+ pricing?: {
184
+ type: 'one-time' | 'subscription';
185
+ price: number;
186
+ wave_scoped_tier?: string;
187
+ };
188
+ /** Tags for search */
189
+ tags?: string[];
190
+ languages?: string[];
191
+ /** Stats (marketplace) */
192
+ stats?: {
193
+ installs: number;
194
+ rating: number;
195
+ reviews: number;
196
+ waves_used: number;
197
+ };
198
+ /** Wave-scoped recommendations */
199
+ wave_scoped?: {
200
+ recommended_tasks?: string[];
201
+ task_keywords?: string[];
202
+ avg_wave_duration?: string;
203
+ complexity?: string;
204
+ };
205
+ }
206
+
207
+ /** Loaded preset with resolved path info */
208
+ export interface LoadedPreset {
209
+ definition: PresetDefinition;
210
+ /** Absolute path to preset directory (or null for _default) */
211
+ path: string | null;
212
+ /** Whether this is the _default preset */
213
+ isDefault: boolean;
214
+ }
215
+
216
+ /** Preset summary for TUI display */
217
+ export interface PresetSummary {
218
+ id: string;
219
+ name: string;
220
+ description?: string;
221
+ rolesCount: number;
222
+ roles: string[];
223
+ isDefault: boolean;
224
+ }
package/src/tui/api.ts CHANGED
@@ -136,6 +136,7 @@ export async function fetchExecStatus(): Promise<ExecStatus> {
136
136
  export async function dispatchWave(directive?: string, options?: {
137
137
  targetRoles?: string[];
138
138
  continuous?: boolean;
139
+ preset?: string;
139
140
  }): Promise<WaveResponse> {
140
141
  return fetchJson<WaveResponse>('/api/jobs', {
141
142
  method: 'POST',
@@ -144,6 +145,7 @@ export async function dispatchWave(directive?: string, options?: {
144
145
  directive: directive ?? '',
145
146
  targetRoles: options?.targetRoles,
146
147
  continuous: options?.continuous ?? false,
148
+ preset: options?.preset,
147
149
  },
148
150
  });
149
151
  }
@@ -211,6 +213,21 @@ export async function cleanupSessions(): Promise<{ cleaned: number; remaining: n
211
213
  return fetchJson<{ cleaned: number; remaining: number }>('/api/active-sessions/cleanup', { method: 'POST' });
212
214
  }
213
215
 
216
+ /* ─── Presets ─── */
217
+
218
+ export interface PresetSummary {
219
+ id: string;
220
+ name: string;
221
+ description?: string;
222
+ rolesCount: number;
223
+ roles: string[];
224
+ isDefault: boolean;
225
+ }
226
+
227
+ export async function fetchPresets(): Promise<PresetSummary[]> {
228
+ return fetchJson<PresetSummary[]>('/api/presets');
229
+ }
230
+
214
231
  /* ─── Knowledge docs ─── */
215
232
 
216
233
  export interface KnowledgeDoc {
package/src/tui/app.tsx CHANGED
@@ -21,7 +21,7 @@ import { useApi } from './hooks/useApi';
21
21
  import { useSSE } from './hooks/useSSE';
22
22
  import { useCommand, type WaveInfo } from './hooks/useCommand';
23
23
  import { dispatchWave } from './api';
24
- import type { ActiveSessionInfo } from './api';
24
+ import type { ActiveSessionInfo, PresetSummary } from './api';
25
25
  import { buildOrgTree, flattenOrgRoleIds } from './store';
26
26
 
27
27
  type Mode = 'command' | 'panel';
@@ -231,6 +231,10 @@ export const App: React.FC = () => {
231
231
  // System messages (command feedback displayed in stream area)
232
232
  const [systemMessages, setSystemMessages] = useState<StreamLine[]>([]);
233
233
 
234
+ // Preset selection state (for /new without args)
235
+ const [pendingPresetSelect, setPendingPresetSelect] = useState<PresetSummary[] | null>(null);
236
+ const selectedPresetRef = useRef<string | null>(null);
237
+
234
238
  // Terminal full height with resize tracking (minus 1 for wide-char overflow safety)
235
239
  const [termHeight, setTermHeight] = useState((process.stdout.rows || 30) - 1);
236
240
 
@@ -337,21 +341,24 @@ export const App: React.FC = () => {
337
341
  return waves.find(w => w.waveId === focusedWaveId)?.startedAt ?? 0;
338
342
  }, [focusedWaveId, waves]);
339
343
 
344
+ // Wave creation callback — shared by useCommand and preset selection flow
345
+ const onWaveCreated = useCallback((newWaveId: string, directive: string) => {
346
+ const newWave: WaveInfo = {
347
+ waveId: newWaveId,
348
+ directive,
349
+ startedAt: Date.now(),
350
+ };
351
+ setWaves(prev => [...prev, newWave]);
352
+ setFocusedWaveId(newWaveId);
353
+ sse.clearEvents();
354
+ api.refresh();
355
+ }, [sse, api]);
356
+
340
357
  // Command handler
341
358
  const { execute } = useCommand({
342
359
  focusedWaveId,
343
360
  waves,
344
- onWaveCreated: (newWaveId, directive) => {
345
- const newWave: WaveInfo = {
346
- waveId: newWaveId,
347
- directive,
348
- startedAt: Date.now(),
349
- };
350
- setWaves(prev => [...prev, newWave]);
351
- setFocusedWaveId(newWaveId);
352
- sse.clearEvents();
353
- api.refresh();
354
- },
361
+ onWaveCreated,
355
362
  onFocusWave: (waveId) => {
356
363
  setFocusedWaveId(waveId);
357
364
  sse.clearEvents();
@@ -366,6 +373,44 @@ export const App: React.FC = () => {
366
373
  const handleCommandSubmit = useCallback(async (input: string) => {
367
374
  // User input is already shown by CommandMode (immediate commit to Static)
368
375
 
376
+ // Preset selection mode: user types a number to pick preset
377
+ if (pendingPresetSelect) {
378
+ const trimmed = input.trim();
379
+ const idx = parseInt(trimmed, 10);
380
+ if (!isNaN(idx) && idx >= 1 && idx <= pendingPresetSelect.length) {
381
+ const selected = pendingPresetSelect[idx - 1];
382
+ setPendingPresetSelect(null);
383
+ addSystemMessage(`Selected: ${selected.name}. Type your directive:`, 'cyan');
384
+ // Store selected preset for next input
385
+ selectedPresetRef.current = selected.id;
386
+ return;
387
+ }
388
+ // If user typed text instead of number, treat as directive with selected/default preset
389
+ const presetId = selectedPresetRef.current || 'default';
390
+ setPendingPresetSelect(null);
391
+ selectedPresetRef.current = null;
392
+ try {
393
+ const waveResult = await dispatchWave(trimmed || undefined, { preset: presetId });
394
+ onWaveCreated(waveResult.waveId, trimmed);
395
+ } catch (err) {
396
+ addSystemMessage(`Wave failed: ${err instanceof Error ? err.message : 'unknown'}`, 'red');
397
+ }
398
+ return;
399
+ }
400
+
401
+ // If a preset was selected previously, this input is the directive
402
+ if (selectedPresetRef.current) {
403
+ const presetId = selectedPresetRef.current;
404
+ selectedPresetRef.current = null;
405
+ try {
406
+ const waveResult = await dispatchWave(input.trim() || undefined, { preset: presetId });
407
+ onWaveCreated(waveResult.waveId, input.trim());
408
+ } catch (err) {
409
+ addSystemMessage(`Wave failed: ${err instanceof Error ? err.message : 'unknown'}`, 'red');
410
+ }
411
+ return;
412
+ }
413
+
369
414
  const result = await execute(input);
370
415
 
371
416
  switch (result.type) {
@@ -513,6 +558,7 @@ export const App: React.FC = () => {
513
558
  addSystemMessage(' /sessions Sessions + ports (kill/cleanup)', 'white');
514
559
  addSystemMessage(' /kill <id> Kill a session', 'white');
515
560
  addSystemMessage(' /cleanup Remove dead sessions', 'white');
561
+ addSystemMessage(' /preset list Installed presets', 'white');
516
562
  addSystemMessage(' /help This help', 'white');
517
563
  addSystemMessage(' /quit Exit', 'white');
518
564
  addSystemMessage('Keys: [Tab] team panel [1-9] wave [Esc] back [Ctrl+C] quit', 'gray');
@@ -526,6 +572,46 @@ export const App: React.FC = () => {
526
572
  addSystemMessage(`Sessions: ${api.sessions.length} Active: ${activeCount} Waves: ${waves.length} Ports: ${api.portSummary.totalPorts}`, 'white');
527
573
  }
528
574
  break;
575
+ case 'preset_list': {
576
+ const presets = result.presets ?? [];
577
+ if (presets.length === 0) {
578
+ addSystemMessage('No presets installed.', 'gray');
579
+ } else {
580
+ addSystemMessage('Installed presets:', 'cyan');
581
+ for (const p of presets) {
582
+ const star = p.isDefault ? ' \u2605' : '';
583
+ const desc = p.description ? ` \u2014 ${p.description}` : '';
584
+ addSystemMessage(` ${p.id} (${p.rolesCount} roles)${desc}${star}`, p.isDefault ? 'green' : 'white');
585
+ }
586
+ }
587
+ break;
588
+ }
589
+ case 'preset_select': {
590
+ const presets = result.presets ?? [];
591
+ if (presets.length === 0) {
592
+ addSystemMessage('No presets. Creating wave with default team.', 'gray');
593
+ try {
594
+ const waveResult = await dispatchWave();
595
+ onWaveCreated(waveResult.waveId, '');
596
+ } catch { /* ignore */ }
597
+ } else if (presets.length === 1) {
598
+ // Only default → show prompt to enter directive
599
+ addSystemMessage('Only default preset available. Type your directive:', 'gray');
600
+ } else {
601
+ // Multiple presets → show selection
602
+ addSystemMessage('Select a team preset for this wave:', 'cyan');
603
+ for (let i = 0; i < presets.length; i++) {
604
+ const p = presets[i];
605
+ const star = p.isDefault ? ' \u2605' : '';
606
+ const desc = p.description ? ` \u2014 ${p.description}` : '';
607
+ addSystemMessage(` ${i + 1}. ${p.name} (${p.rolesCount} roles)${desc}${star}`, p.isDefault ? 'green' : 'white');
608
+ }
609
+ addSystemMessage('Type a number to select, then enter your directive.', 'gray');
610
+ // Store presets for number selection — handled via pendingPresetSelect
611
+ setPendingPresetSelect(presets);
612
+ }
613
+ break;
614
+ }
529
615
  case 'panel':
530
616
  break;
531
617
  case 'quit':
@@ -535,7 +621,7 @@ export const App: React.FC = () => {
535
621
  addSystemMessage(result.message, 'green');
536
622
  }
537
623
  }
538
- }, [execute, addSystemMessage, addSystemLines, focusedWaveId, focusedWaveIndex, derivedWaveStatus, api.sessions.length, activeCount, waves, api.activeSessions, api.portSummary]);
624
+ }, [execute, addSystemMessage, addSystemLines, focusedWaveId, focusedWaveIndex, derivedWaveStatus, api.sessions.length, activeCount, waves, api.activeSessions, api.portSummary, pendingPresetSelect, onWaveCreated]);
539
625
 
540
626
  // Global key handler: Tab to toggle mode, Ctrl+C always exits
541
627
  useInput((input, key) => {
@@ -299,6 +299,7 @@ const COMMANDS: Array<{ cmd: string; desc: string }> = [
299
299
  { cmd: '/sessions', desc: 'Active sessions' },
300
300
  { cmd: '/kill <id>', desc: 'Kill session' },
301
301
  { cmd: '/cleanup', desc: 'Remove dead' },
302
+ { cmd: '/preset list', desc: 'Installed presets' },
302
303
  { cmd: '/help', desc: 'Help' },
303
304
  { cmd: '/quit', desc: 'Exit' },
304
305
  ];
@@ -139,7 +139,7 @@ function readFilePreview(filePath: string, maxLines: number): string[] {
139
139
  }
140
140
  }
141
141
 
142
- export const PanelMode: React.FC<PanelModeProps> = ({
142
+ export const PanelMode: React.FC<PanelModeProps> = React.memo(({
143
143
  tree, flatRoles, events, selectedRoleIndex, selectedRoleId,
144
144
  streamStatus, waveId, activeSessions, allSessions, companyRoot, waves,
145
145
  focusedWaveId, onMove, onSelect, onEscape, onFocusWave,
@@ -545,4 +545,4 @@ export const PanelMode: React.FC<PanelModeProps> = ({
545
545
  </Box>
546
546
  </Box>
547
547
  );
548
- };
548
+ }));
@@ -116,7 +116,7 @@ function renderEvent(event: SSEEvent): { content: string; contentColor: string }
116
116
  }
117
117
  }
118
118
 
119
- export const StreamView: React.FC<StreamViewProps> = ({
119
+ export const StreamView: React.FC<StreamViewProps> = React.memo(({
120
120
  events,
121
121
  allRoleIds,
122
122
  streamStatus,
@@ -162,10 +162,10 @@ export const StreamView: React.FC<StreamViewProps> = ({
162
162
  <Box key={`${event.seq}-${i}`}>
163
163
  <Text color="gray" dimColor>{formatTime(event.ts)} </Text>
164
164
  <Text color={roleColor} bold>{event.roleId.padEnd(12)}</Text>
165
- <Text color={rendered.contentColor} wrap="wrap">{rendered.content}</Text>
165
+ <Text color={rendered.contentColor} wrap="truncate">{rendered.content}</Text>
166
166
  </Box>
167
167
  );
168
168
  })}
169
169
  </Box>
170
170
  );
171
- };
171
+ }));
@@ -16,7 +16,8 @@
16
16
  */
17
17
 
18
18
  import { useCallback } from 'react';
19
- import { dispatchWave, sendDirective, stopWave, fetchJson, killSession, cleanupSessions, fetchActiveSessions } from '../api';
19
+ import { dispatchWave, sendDirective, stopWave, fetchJson, killSession, cleanupSessions, fetchActiveSessions, fetchPresets } from '../api';
20
+ import type { PresetSummary } from '../api';
20
21
 
21
22
  export interface WaveInfo {
22
23
  waveId: string;
@@ -25,9 +26,10 @@ export interface WaveInfo {
25
26
  }
26
27
 
27
28
  export interface CommandResult {
28
- type: 'success' | 'error' | 'info' | 'wave_started' | 'directive_sent' | 'stopped' | 'quit' | 'help' | 'panel' | 'waves_list' | 'focus_changed' | 'agents' | 'ports' | 'sessions' | 'cleanup' | 'docs' | 'read_file' | 'open_file';
29
+ type: 'success' | 'error' | 'info' | 'wave_started' | 'directive_sent' | 'stopped' | 'quit' | 'help' | 'panel' | 'waves_list' | 'focus_changed' | 'agents' | 'ports' | 'sessions' | 'cleanup' | 'docs' | 'read_file' | 'open_file' | 'preset_list' | 'preset_select';
29
30
  message: string;
30
31
  waveId?: string;
32
+ presets?: PresetSummary[];
31
33
  }
32
34
 
33
35
  async function postAssign(roleId: string, task: string): Promise<{ waveId?: string }> {
@@ -80,6 +82,15 @@ export function useCommand(options: UseCommandOptions) {
80
82
 
81
83
  case 'new': {
82
84
  const directive = args || undefined;
85
+ if (!directive) {
86
+ // No args → show preset selection UI
87
+ try {
88
+ const presets = await fetchPresets();
89
+ return { type: 'preset_select', message: '', presets };
90
+ } catch (err) {
91
+ return { type: 'error', message: `Failed to load presets: ${err instanceof Error ? err.message : 'unknown'}` };
92
+ }
93
+ }
83
94
  try {
84
95
  const result = await dispatchWave(directive);
85
96
  onWaveCreated(result.waveId, directive ?? '');
@@ -168,6 +179,19 @@ export function useCommand(options: UseCommandOptions) {
168
179
  }
169
180
  }
170
181
 
182
+ case 'preset': {
183
+ const subCmd = args.split(/\s+/)[0]?.toLowerCase() || 'list';
184
+ if (subCmd === 'list' || !subCmd) {
185
+ try {
186
+ const presets = await fetchPresets();
187
+ return { type: 'preset_list', message: '', presets };
188
+ } catch (err) {
189
+ return { type: 'error', message: `Failed to load presets: ${err instanceof Error ? err.message : 'unknown'}` };
190
+ }
191
+ }
192
+ return { type: 'error', message: `Unknown preset command: ${subCmd}. Try: /preset list` };
193
+ }
194
+
171
195
  case 'roles':
172
196
  onShowPanel();
173
197
  return { type: 'panel', message: '' };