tycono 0.1.64 → 0.1.66

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 (35) hide show
  1. package/bin/tycono.ts +13 -4
  2. package/package.json +1 -1
  3. package/src/api/src/create-server.ts +5 -1
  4. package/src/api/src/engine/agent-loop.ts +17 -6
  5. package/src/api/src/engine/context-assembler.ts +156 -48
  6. package/src/api/src/engine/knowledge-gate.ts +335 -0
  7. package/src/api/src/engine/llm-adapter.ts +7 -1
  8. package/src/api/src/engine/runners/claude-cli.ts +98 -116
  9. package/src/api/src/engine/runners/types.ts +2 -0
  10. package/src/api/src/engine/tools/executor.ts +3 -5
  11. package/src/api/src/routes/active-sessions.ts +143 -0
  12. package/src/api/src/routes/coins.ts +137 -0
  13. package/src/api/src/routes/execute.ts +158 -48
  14. package/src/api/src/routes/knowledge.ts +30 -0
  15. package/src/api/src/routes/operations.ts +48 -11
  16. package/src/api/src/routes/sessions.ts +1 -1
  17. package/src/api/src/routes/setup.ts +68 -1
  18. package/src/api/src/routes/speech.ts +334 -142
  19. package/src/api/src/services/activity-stream.ts +1 -1
  20. package/src/api/src/services/job-manager.ts +185 -9
  21. package/src/api/src/services/port-registry.ts +222 -0
  22. package/src/api/src/services/scaffold.ts +90 -0
  23. package/src/api/src/services/session-store.ts +75 -5
  24. package/src/web/dist/assets/index-BDLT2xew.js +109 -0
  25. package/src/web/dist/assets/index-LvS5V8aP.css +1 -0
  26. package/src/web/dist/assets/{preview-app-qIFqrb-y.js → preview-app-AJtyaM6L.js} +1 -1
  27. package/src/web/dist/index.html +2 -2
  28. package/templates/skills/_manifest.json +6 -0
  29. package/templates/skills/agent-browser/SKILL.md +159 -0
  30. package/templates/skills/agent-browser/meta.json +19 -0
  31. package/templates/teams/agency.json +3 -3
  32. package/templates/teams/research.json +3 -3
  33. package/templates/teams/startup.json +3 -3
  34. package/src/web/dist/assets/index-B3dNhn76.js +0 -101
  35. package/src/web/dist/assets/index-C7IEX_o_.css +0 -1
@@ -37,6 +37,8 @@ export interface RunnerConfig {
37
37
  jobId?: string;
38
38
  teamStatus?: TeamStatus;
39
39
  attachments?: ImageAttachment[];
40
+ /** Selective dispatch scope — only these roles can be dispatched to */
41
+ targetRoles?: string[];
40
42
  }
41
43
 
42
44
  /* ─── Callbacks ───────────────────────────────── */
@@ -4,6 +4,7 @@ import { glob } from 'glob';
4
4
  import type { ToolCall, ToolResult } from '../llm-adapter.js';
5
5
  import { validateWrite, validateRead } from '../authority-validator.js';
6
6
  import type { OrgTree } from '../org-tree.js';
7
+ import { buildKnowledgeGateWarning } from '../knowledge-gate.js';
7
8
 
8
9
  /* ─── Types ──────────────────────────────────── */
9
10
 
@@ -219,12 +220,9 @@ function writeFile(
219
220
 
220
221
  let result = `File written: ${filePath} (${content.length} chars)`;
221
222
 
222
- // AKB Level 0 hint: 새 .md 파일 생성 시 (journal 제외)
223
+ // Knowledge Gate: 새 .md 파일 생성 시 자동 검색 + 경고 (journal 제외)
223
224
  if (isNewFile && filePath.endsWith('.md') && !filePath.includes('journal/')) {
224
- result += '\n\n[AKB] .md 파일입니다. 확인: '
225
- + '(1) search_files로 기존 문서를 검색했는가? '
226
- + '(2) 관련 Hub에 등록했는가? '
227
- + '(3) cross-link를 추가했는가?';
225
+ result += buildKnowledgeGateWarning(companyRoot, filePath, content);
228
226
  }
229
227
 
230
228
  return { tool_use_id: id, content: result };
@@ -0,0 +1,143 @@
1
+ /**
2
+ * active-sessions.ts — Active session visibility API
3
+ *
4
+ * Exposes session + port + worktree state for both UI and AI agents.
5
+ * All sessions sharing the same tycono server origin can query this.
6
+ */
7
+ import { Router } from 'express';
8
+ import { portRegistry } from '../services/port-registry.js';
9
+ import { jobManager } from '../services/job-manager.js';
10
+
11
+ export const activeSessionsRouter = Router();
12
+
13
+ /**
14
+ * GET /api/active-sessions
15
+ * Returns all active sessions with port + worktree info.
16
+ * Used by both the web UI and AI agents (curl).
17
+ */
18
+ activeSessionsRouter.get('/', (_req, res) => {
19
+ const sessions = portRegistry.getAll();
20
+
21
+ // Enrich with job info where available
22
+ const enriched = sessions.map(s => {
23
+ const job = jobManager.getJobInfo(s.sessionId);
24
+ return {
25
+ ...s,
26
+ jobStatus: job?.status ?? null,
27
+ roleName: job?.roleId ?? s.roleId,
28
+ alive: s.pid ? isAlive(s.pid) : null,
29
+ };
30
+ });
31
+
32
+ res.json({
33
+ sessions: enriched,
34
+ summary: portRegistry.getSummary(),
35
+ });
36
+ });
37
+
38
+ /**
39
+ * GET /api/active-sessions/:id
40
+ * Get detailed info for a specific session.
41
+ */
42
+ activeSessionsRouter.get('/:id', (req, res) => {
43
+ const session = portRegistry.get(req.params.id);
44
+ if (!session) {
45
+ res.status(404).json({ error: 'Session not found' });
46
+ return;
47
+ }
48
+
49
+ const job = jobManager.getJobInfo(session.sessionId);
50
+
51
+ res.json({
52
+ ...session,
53
+ jobStatus: job?.status ?? null,
54
+ roleName: job?.roleId ?? session.roleId,
55
+ alive: session.pid ? isAlive(session.pid) : null,
56
+ job: job ?? null,
57
+ });
58
+ });
59
+
60
+ /**
61
+ * DELETE /api/active-sessions/:id
62
+ * Stop a session — release ports + clean up.
63
+ */
64
+ activeSessionsRouter.delete('/:id', (req, res) => {
65
+ const sessionId = req.params.id;
66
+ const session = portRegistry.get(sessionId);
67
+
68
+ if (!session) {
69
+ res.status(404).json({ error: 'Session not found' });
70
+ return;
71
+ }
72
+
73
+ // Try to abort the job if running
74
+ jobManager.abortJob(sessionId);
75
+
76
+ // Release ports
77
+ portRegistry.release(sessionId);
78
+
79
+ res.json({ ok: true, released: session.ports });
80
+ });
81
+
82
+ /**
83
+ * POST /api/active-sessions/cleanup
84
+ * Clean up all dead sessions (PID gone).
85
+ */
86
+ activeSessionsRouter.post('/cleanup', (_req, res) => {
87
+ const result = portRegistry.cleanup();
88
+ res.json({
89
+ cleaned: result.cleaned.length,
90
+ remaining: result.remaining.length,
91
+ sessions: result.cleaned.map(s => ({
92
+ sessionId: s.sessionId,
93
+ roleId: s.roleId,
94
+ ports: s.ports,
95
+ })),
96
+ });
97
+ });
98
+
99
+ /**
100
+ * POST /api/active-sessions/register
101
+ * Manually register a session (for external Claude Code sessions).
102
+ */
103
+ activeSessionsRouter.post('/register', async (req, res) => {
104
+ const { sessionId, roleId, task, pid, worktreePath } = req.body;
105
+
106
+ if (!sessionId || !roleId) {
107
+ res.status(400).json({ error: 'sessionId and roleId are required' });
108
+ return;
109
+ }
110
+
111
+ // Check if already registered
112
+ const existing = portRegistry.get(sessionId);
113
+ if (existing) {
114
+ res.json({ ok: true, ports: existing.ports, existing: true });
115
+ return;
116
+ }
117
+
118
+ const ports = await portRegistry.allocate(
119
+ sessionId,
120
+ roleId,
121
+ task || 'Manual session',
122
+ );
123
+
124
+ if (pid || worktreePath) {
125
+ portRegistry.update(sessionId, {
126
+ pid: pid ?? undefined,
127
+ worktreePath: worktreePath ?? undefined,
128
+ });
129
+ }
130
+
131
+ res.json({ ok: true, ports, existing: false });
132
+ });
133
+
134
+ /* ─── Helpers ────────────────────────────── */
135
+
136
+ function isAlive(pid: number): boolean {
137
+ try {
138
+ process.kill(pid, 0);
139
+ return true;
140
+ } catch {
141
+ return false;
142
+ }
143
+ }
@@ -0,0 +1,137 @@
1
+ import { Router, Request, Response, NextFunction } from 'express';
2
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { COMPANY_ROOT } from '../services/file-reader.js';
5
+
6
+ export const coinsRouter = Router();
7
+
8
+ /* ── Types ── */
9
+
10
+ interface CoinTransaction {
11
+ ts: string;
12
+ amount: number;
13
+ reason: string;
14
+ ref?: string; // questId, jobId, etc.
15
+ }
16
+
17
+ interface CoinsData {
18
+ balance: number;
19
+ totalEarned: number;
20
+ totalSpent: number;
21
+ transactions: CoinTransaction[];
22
+ }
23
+
24
+ /* ── Persistence ── */
25
+
26
+ const COINS_FILE = () => join(COMPANY_ROOT, '.tycono', 'coins.json');
27
+
28
+ const DEFAULT_DATA: CoinsData = {
29
+ balance: 0,
30
+ totalEarned: 0,
31
+ totalSpent: 0,
32
+ transactions: [],
33
+ };
34
+
35
+ function readCoins(): CoinsData {
36
+ try {
37
+ if (existsSync(COINS_FILE())) {
38
+ return JSON.parse(readFileSync(COINS_FILE(), 'utf-8'));
39
+ }
40
+ } catch { /* use defaults */ }
41
+ return { ...DEFAULT_DATA, transactions: [] };
42
+ }
43
+
44
+ function writeCoins(data: CoinsData) {
45
+ mkdirSync(join(COMPANY_ROOT, '.tycono'), { recursive: true });
46
+ writeFileSync(COINS_FILE(), JSON.stringify(data, null, 2) + '\n');
47
+ }
48
+
49
+ /* ── Routes ── */
50
+
51
+ // GET /api/coins — current balance + summary
52
+ coinsRouter.get('/', (_req: Request, res: Response, next: NextFunction) => {
53
+ try {
54
+ res.json(readCoins());
55
+ } catch (err) { next(err); }
56
+ });
57
+
58
+ // POST /api/coins/earn — add coins
59
+ coinsRouter.post('/earn', (req: Request, res: Response, next: NextFunction) => {
60
+ try {
61
+ const { amount, reason, ref } = req.body;
62
+ if (typeof amount !== 'number' || amount <= 0) {
63
+ res.status(400).json({ error: 'amount must be a positive number' });
64
+ return;
65
+ }
66
+ const data = readCoins();
67
+ // Idempotency: skip if same ref already earned (prevents double quest rewards)
68
+ if (ref && data.transactions.some(t => t.ref === ref && t.amount > 0)) {
69
+ res.json({ ok: true, balance: data.balance, skipped: true });
70
+ return;
71
+ }
72
+ const tx: CoinTransaction = {
73
+ ts: new Date().toISOString(),
74
+ amount,
75
+ reason: reason || 'earn',
76
+ ref,
77
+ };
78
+ data.balance += amount;
79
+ data.totalEarned += amount;
80
+ data.transactions.push(tx);
81
+ writeCoins(data);
82
+ res.json({ ok: true, balance: data.balance, transaction: tx });
83
+ } catch (err) { next(err); }
84
+ });
85
+
86
+ // POST /api/coins/spend — deduct coins
87
+ coinsRouter.post('/spend', (req: Request, res: Response, next: NextFunction) => {
88
+ try {
89
+ const { amount, reason, ref } = req.body;
90
+ if (typeof amount !== 'number' || amount <= 0) {
91
+ res.status(400).json({ error: 'amount must be a positive number' });
92
+ return;
93
+ }
94
+ const data = readCoins();
95
+ if (data.balance < amount) {
96
+ res.status(400).json({ error: 'insufficient balance', balance: data.balance, required: amount });
97
+ return;
98
+ }
99
+ const tx: CoinTransaction = {
100
+ ts: new Date().toISOString(),
101
+ amount: -amount,
102
+ reason: reason || 'spend',
103
+ ref,
104
+ };
105
+ data.balance -= amount;
106
+ data.totalSpent += amount;
107
+ data.transactions.push(tx);
108
+ writeCoins(data);
109
+ res.json({ ok: true, balance: data.balance, transaction: tx });
110
+ } catch (err) { next(err); }
111
+ });
112
+
113
+ // POST /api/coins/migrate — initial coin grant for existing users
114
+ coinsRouter.post('/migrate', (req: Request, res: Response, next: NextFunction) => {
115
+ try {
116
+ const data = readCoins();
117
+ // Only migrate once
118
+ if (data.totalEarned > 0) {
119
+ res.json({ ok: true, skipped: true, balance: data.balance });
120
+ return;
121
+ }
122
+ const { completedQuests = 0 } = req.body;
123
+ const grantAmount = completedQuests > 0 ? completedQuests * 2000 : 5000;
124
+ const reason = completedQuests > 0 ? `migration: ${completedQuests} quests × 2,000` : 'welcome bonus';
125
+ const tx: CoinTransaction = {
126
+ ts: new Date().toISOString(),
127
+ amount: grantAmount,
128
+ reason,
129
+ ref: 'migration',
130
+ };
131
+ data.balance = grantAmount;
132
+ data.totalEarned = grantAmount;
133
+ data.transactions.push(tx);
134
+ writeCoins(data);
135
+ res.json({ ok: true, balance: data.balance, granted: grantAmount, reason });
136
+ } catch (err) { next(err); }
137
+ });
@@ -7,6 +7,7 @@ import { buildOrgTree, canDispatchTo, getSubordinates } from '../engine/org-tree
7
7
  import { createRunner, type RunnerResult } from '../engine/runners/index.js';
8
8
  import {
9
9
  getSession,
10
+ createSession,
10
11
  addMessage,
11
12
  updateMessage,
12
13
  type Message,
@@ -133,7 +134,7 @@ function handleStartJob(body: Record<string, unknown>, res: ServerResponse): voi
133
134
  const targetRole = (body.targetRole as string) || 'cto';
134
135
  const parentJobId = body.parentJobId as string | undefined;
135
136
 
136
- // Wave shorthand — broadcast to ALL C-level direct reports
137
+ // Wave shorthand — broadcast to C-level direct reports (optionally filtered)
137
138
  if (type === 'wave') {
138
139
  if (!directive) {
139
140
  jsonResponse(res, 400, { error: 'directive is required for wave jobs' });
@@ -141,26 +142,74 @@ function handleStartJob(body: Record<string, unknown>, res: ServerResponse): voi
141
142
  }
142
143
 
143
144
  const orgTree = buildOrgTree(COMPANY_ROOT);
144
- const cLevelRoles = getSubordinates(orgTree, 'ceo');
145
+ let cLevelRoles = getSubordinates(orgTree, 'ceo');
146
+
147
+ // Selective dispatch: filter by targetRoles if provided
148
+ const targetRoles = body.targetRoles as string[] | undefined;
149
+ if (targetRoles && Array.isArray(targetRoles) && targetRoles.length > 0) {
150
+ const allowed = new Set(targetRoles);
151
+ cLevelRoles = cLevelRoles.filter(r => allowed.has(r));
152
+ }
145
153
 
146
154
  if (cLevelRoles.length === 0) {
147
155
  jsonResponse(res, 400, { error: 'No C-level roles found to dispatch wave.' });
148
156
  return;
149
157
  }
150
158
 
159
+ // Resolve full targetRoles scope for re-dispatch filtering
160
+ // Include both the C-level roles AND any sub-roles from targetRoles
161
+ const fullTargetScope = targetRoles && targetRoles.length > 0 ? targetRoles : undefined;
162
+
163
+ // D-014: Create Wave meta + Sessions for each target role
164
+ const waveId = `wave-${Date.now()}`;
151
165
  const jobIds: string[] = [];
166
+ const sessionIds: string[] = [];
167
+
152
168
  for (const cRole of cLevelRoles) {
169
+ // Create a Session for this role (D-014: Wave = Session batch creation)
170
+ const session = createSession(cRole, {
171
+ mode: 'do',
172
+ source: 'wave',
173
+ waveId,
174
+ });
175
+ sessionIds.push(session.id);
176
+
177
+ // Add CEO directive as the first message in the session
178
+ const ceoMsg: Message = {
179
+ id: `msg-${Date.now()}-ceo-${cRole}`,
180
+ from: 'ceo',
181
+ content: directive,
182
+ type: 'directive',
183
+ status: 'done',
184
+ timestamp: new Date().toISOString(),
185
+ };
186
+ addMessage(session.id, ceoMsg);
187
+
153
188
  const job = jobManager.startJob({
154
189
  type: 'wave',
155
190
  roleId: cRole,
156
191
  task: `[CEO Wave] ${directive}`,
157
192
  sourceRole: 'ceo',
158
193
  parentJobId,
194
+ targetRoles: fullTargetScope,
195
+ sessionId: session.id, // D-014: link job to session
159
196
  });
160
197
  jobIds.push(job.id);
198
+
199
+ // Add a role message (will be updated as execution progresses)
200
+ const roleMsg: Message = {
201
+ id: `msg-${Date.now() + 1}-role-${cRole}`,
202
+ from: 'role',
203
+ content: '',
204
+ type: 'conversation',
205
+ status: 'streaming',
206
+ timestamp: new Date().toISOString(),
207
+ jobId: job.id,
208
+ };
209
+ addMessage(session.id, roleMsg, true);
161
210
  }
162
211
 
163
- jsonResponse(res, 200, { jobIds });
212
+ jsonResponse(res, 200, { jobIds, waveId, sessionIds });
164
213
  return;
165
214
  }
166
215
 
@@ -176,16 +225,53 @@ function handleStartJob(body: Record<string, unknown>, res: ServerResponse): voi
176
225
  return;
177
226
  }
178
227
 
228
+ // D-014: Create/find session for CEO assigns (not for dispatch child jobs)
229
+ let sessionId: string | undefined;
230
+ if (sourceRole === 'ceo' && !parentJobId) {
231
+ const session = createSession(roleId, {
232
+ mode: readOnly ? 'talk' : 'do',
233
+ source: 'dispatch',
234
+ });
235
+ sessionId = session.id;
236
+
237
+ // Add CEO message
238
+ const ceoMsg: Message = {
239
+ id: `msg-${Date.now()}-ceo`,
240
+ from: 'ceo',
241
+ content: task,
242
+ type: readOnly ? 'conversation' : 'directive',
243
+ status: 'done',
244
+ timestamp: new Date().toISOString(),
245
+ };
246
+ addMessage(session.id, ceoMsg);
247
+ }
248
+
179
249
  const job = jobManager.startJob({
180
- type: 'assign',
250
+ type: readOnly ? 'consult' : 'assign',
181
251
  roleId,
182
252
  task,
183
253
  sourceRole,
184
254
  readOnly,
185
255
  parentJobId,
256
+ sessionId,
186
257
  });
187
258
 
188
- jsonResponse(res, 200, { jobId: job.id });
259
+ // D-014: Add role message linked to job
260
+ if (sessionId) {
261
+ const roleMsg: Message = {
262
+ id: `msg-${Date.now() + 1}-role`,
263
+ from: 'role',
264
+ content: '',
265
+ type: 'conversation',
266
+ status: 'streaming',
267
+ timestamp: new Date().toISOString(),
268
+ jobId: job.id,
269
+ readOnly: readOnly || undefined,
270
+ };
271
+ addMessage(sessionId, roleMsg, true);
272
+ }
273
+
274
+ jsonResponse(res, 200, { jobId: job.id, ...(sessionId && { sessionId }) });
189
275
  }
190
276
 
191
277
  /* ─── GET /api/jobs ──────────────────────── */
@@ -296,7 +382,7 @@ function handleReplyToJob(jobId: string, body: Record<string, unknown>, res: Ser
296
382
 
297
383
  const newJob = jobManager.replyToJob(jobId, message, responderRole);
298
384
  if (!newJob) {
299
- jsonResponse(res, 400, { error: 'Job not found or not awaiting input' });
385
+ jsonResponse(res, 400, { error: 'Job not found or not in a replyable state' });
300
386
  return;
301
387
  }
302
388
 
@@ -308,60 +394,56 @@ function handleReplyToJob(jobId: string, body: Record<string, unknown>, res: Ser
308
394
  function handleSaveWave(body: Record<string, unknown>, res: ServerResponse): void {
309
395
  const directive = body.directive as string;
310
396
  const jobIds = body.jobIds as string[];
397
+ const sessionIds = body.sessionIds as string[] | undefined;
398
+ const waveId = body.waveId as string | undefined;
311
399
 
312
400
  if (!directive || !jobIds || jobIds.length === 0) {
313
401
  jsonResponse(res, 400, { error: 'directive and jobIds are required' });
314
402
  return;
315
403
  }
316
404
 
317
- // Build wave summary from job streams
318
405
  const now = new Date();
319
406
  const dateStr = now.toISOString().slice(0, 10);
320
- const timeStr = now.toTimeString().slice(0, 5);
321
- const lines: string[] = [
322
- `# Wave — ${dateStr} ${timeStr}`,
323
- '',
324
- `> ${directive}`,
325
- '',
326
- ];
407
+
408
+ // Structured data for JSON replay
409
+ interface WaveRoleData {
410
+ roleId: string;
411
+ roleName: string;
412
+ jobId: string;
413
+ status: string;
414
+ events: ReturnType<typeof ActivityStream.readAll>;
415
+ childJobs: Array<{ roleId: string; roleName: string; jobId: string; status: string; events: ReturnType<typeof ActivityStream.readAll> }>;
416
+ }
417
+ const rolesData: WaveRoleData[] = [];
327
418
 
328
419
  for (const jobId of jobIds) {
329
420
  const events = ActivityStream.readAll(jobId);
330
421
  const startEvent = events.find(e => e.type === 'job:start');
331
422
  const roleId = startEvent?.roleId ?? 'unknown';
332
- const doneEvent = events.find(e => e.type === 'job:done' || e.type === 'job:awaiting_input');
333
-
334
- lines.push(`## ${roleId.toUpperCase()}`);
335
- lines.push('');
423
+ const roleName = (startEvent?.data?.roleName as string) ?? roleId;
424
+ const doneEvent = events.find(e => e.type === 'job:done' || e.type === 'job:awaiting_input' || e.type === 'job:error');
425
+ const status = doneEvent?.type === 'job:done' ? 'done' : doneEvent?.type === 'job:error' ? 'error' : doneEvent?.type === 'job:awaiting_input' ? 'awaiting_input' : 'unknown';
336
426
 
337
- // Collect text output
338
- const textParts: string[] = [];
427
+ // Collect child jobs (dispatched sub-roles)
428
+ const childJobs: WaveRoleData['childJobs'] = [];
339
429
  for (const e of events) {
340
- if (e.type === 'text' && typeof e.data.text === 'string') {
341
- textParts.push(e.data.text);
430
+ if (e.type === 'dispatch:start' && e.data.childJobId) {
431
+ const childJobId = e.data.childJobId as string;
432
+ const targetRoleId = (e.data.targetRoleId as string) ?? 'unknown';
433
+ const childEvents = ActivityStream.readAll(childJobId);
434
+ const childDone = childEvents.find(ce => ce.type === 'job:done' || ce.type === 'job:error' || ce.type === 'job:awaiting_input');
435
+ const childStatus = childDone?.type === 'job:done' ? 'done' : childDone?.type === 'job:error' ? 'error' : 'unknown';
436
+ childJobs.push({
437
+ roleId: targetRoleId,
438
+ roleName: (childEvents.find(ce => ce.type === 'job:start')?.data?.roleName as string) ?? targetRoleId,
439
+ jobId: childJobId,
440
+ status: childStatus,
441
+ events: childEvents,
442
+ });
342
443
  }
343
444
  }
344
- const fullText = textParts.join('');
345
- // Take last 1500 chars as summary
346
- const summary = fullText.length > 1500
347
- ? '...' + fullText.slice(-1500)
348
- : fullText;
349
-
350
- if (summary.trim()) {
351
- lines.push(summary.trim());
352
- } else {
353
- lines.push('(No text output)');
354
- }
355
445
 
356
- if (doneEvent) {
357
- const turns = doneEvent.data.turns as number ?? 0;
358
- const tools = doneEvent.data.toolCalls as number ?? 0;
359
- lines.push('');
360
- lines.push(`*${turns} turns, ${tools} tool calls*`);
361
- }
362
- lines.push('');
363
- lines.push('---');
364
- lines.push('');
446
+ rolesData.push({ roleId, roleName, jobId, status, events, childJobs });
365
447
  }
366
448
 
367
449
  // Write to operations/waves/
@@ -369,11 +451,23 @@ function handleSaveWave(body: Record<string, unknown>, res: ServerResponse): voi
369
451
  if (!fs.existsSync(wavesDir)) {
370
452
  fs.mkdirSync(wavesDir, { recursive: true });
371
453
  }
372
- const filename = `wave-${dateStr}-${Date.now()}.md`;
373
- const filePath = path.join(wavesDir, filename);
374
- fs.writeFileSync(filePath, lines.join('\n'), 'utf-8');
454
+ const hhmmss = now.toTimeString().slice(0, 8).replace(/:/g, '');
455
+ const baseName = `${dateStr.replace(/-/g, '')}-${hhmmss}-wave`;
456
+ const jsonPath = path.join(wavesDir, `${baseName}.json`);
457
+
458
+ const waveJson = {
459
+ id: baseName,
460
+ directive,
461
+ startedAt: now.toISOString(),
462
+ duration: 0, // Could be computed from events
463
+ roles: rolesData,
464
+ // D-014: Session references for follow-up
465
+ ...(waveId && { waveId }),
466
+ ...(sessionIds && sessionIds.length > 0 && { sessionIds }),
467
+ };
468
+ fs.writeFileSync(jsonPath, JSON.stringify(waveJson, null, 2), 'utf-8');
375
469
 
376
- jsonResponse(res, 200, { ok: true, path: `operations/waves/${filename}` });
470
+ jsonResponse(res, 200, { ok: true, path: `operations/waves/${baseName}.json` });
377
471
  }
378
472
 
379
473
  /* ═══════════════════════════════════════════════
@@ -543,13 +637,23 @@ function handleWave(body: Record<string, unknown>, req: IncomingMessage, res: Se
543
637
  }
544
638
 
545
639
  const orgTree = buildOrgTree(COMPANY_ROOT);
546
- const cLevelRoles = getSubordinates(orgTree, 'ceo');
640
+ let cLevelRoles = getSubordinates(orgTree, 'ceo');
641
+
642
+ // Selective dispatch: filter by targetRoles if provided
643
+ const targetRoles = body.targetRoles as string[] | undefined;
644
+ if (targetRoles && Array.isArray(targetRoles) && targetRoles.length > 0) {
645
+ const allowed = new Set(targetRoles);
646
+ cLevelRoles = cLevelRoles.filter(r => allowed.has(r));
647
+ }
547
648
 
548
649
  if (cLevelRoles.length === 0) {
549
650
  jsonResponse(res, 400, { error: 'No C-level roles found to dispatch wave.' });
550
651
  return;
551
652
  }
552
653
 
654
+ // Resolve full targetRoles scope for re-dispatch filtering
655
+ const fullTargetScope = targetRoles && targetRoles.length > 0 ? targetRoles : undefined;
656
+
553
657
  // Start a job for EACH C-level role
554
658
  const jobs: Job[] = [];
555
659
  for (const cRole of cLevelRoles) {
@@ -558,6 +662,7 @@ function handleWave(body: Record<string, unknown>, req: IncomingMessage, res: Se
558
662
  roleId: cRole,
559
663
  task: `[CEO Wave] ${directive}`,
560
664
  sourceRole: 'ceo',
665
+ targetRoles: fullTargetScope,
561
666
  });
562
667
  jobs.push(job);
563
668
  }
@@ -909,7 +1014,12 @@ function handleSessionMessage(
909
1014
  .then((result: RunnerResult) => {
910
1015
  cleanupSSELifecycle();
911
1016
  cleanupChildSubscriptions();
912
- updateMessage(sessionId, roleMsg.id, { content: roleMsg.content, status: 'done' });
1017
+ updateMessage(sessionId, roleMsg.id, {
1018
+ content: roleMsg.content,
1019
+ status: 'done',
1020
+ turns: result.turns,
1021
+ tokens: result.totalTokens,
1022
+ });
913
1023
  roleStatus.set(roleId, 'idle');
914
1024
  completeActivity(roleId);
915
1025
  for (const d of result.dispatches) {
@@ -10,6 +10,7 @@ import path from 'node:path';
10
10
  import matter from 'gray-matter';
11
11
  import { glob } from 'glob';
12
12
  import { COMPANY_ROOT } from '../services/file-reader.js';
13
+ import { detectDecay, searchRelatedDocs, extractKeywords } from '../engine/knowledge-gate.js';
13
14
 
14
15
  export const knowledgeRouter = Router();
15
16
 
@@ -144,6 +145,35 @@ knowledgeRouter.get('/', (_req: Request, res: Response, next: NextFunction) => {
144
145
  }
145
146
  });
146
147
 
148
+ /* ─── Knowledge Health endpoint ──────────────────── */
149
+
150
+ knowledgeRouter.get('/health', (_req: Request, res: Response, next: NextFunction) => {
151
+ try {
152
+ const report = detectDecay(companyRoot());
153
+ res.json(report);
154
+ } catch (err) {
155
+ next(err);
156
+ }
157
+ });
158
+
159
+ /* ─── Related docs search endpoint ──────────────── */
160
+
161
+ knowledgeRouter.get('/related', (req: Request, res: Response, next: NextFunction) => {
162
+ try {
163
+ const query = String(req.query.q ?? '');
164
+ if (!query) {
165
+ res.status(400).json({ error: 'q parameter required' });
166
+ return;
167
+ }
168
+
169
+ const keywords = extractKeywords(query);
170
+ const docs = searchRelatedDocs(companyRoot(), keywords);
171
+ res.json({ keywords, docs });
172
+ } catch (err) {
173
+ next(err);
174
+ }
175
+ });
176
+
147
177
  /* ─── Single document endpoint ────────────────────── */
148
178
 
149
179
  /* ─── Create document endpoint ───────────────────── */