tycono 0.1.65 → 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.
- package/bin/tycono.ts +13 -4
- package/package.json +1 -1
- package/src/api/src/create-server.ts +5 -1
- package/src/api/src/engine/agent-loop.ts +17 -6
- package/src/api/src/engine/context-assembler.ts +156 -48
- package/src/api/src/engine/knowledge-gate.ts +335 -0
- package/src/api/src/engine/llm-adapter.ts +7 -1
- package/src/api/src/engine/runners/claude-cli.ts +98 -116
- package/src/api/src/engine/runners/types.ts +2 -0
- package/src/api/src/engine/tools/executor.ts +3 -5
- package/src/api/src/routes/active-sessions.ts +143 -0
- package/src/api/src/routes/coins.ts +137 -0
- package/src/api/src/routes/execute.ts +158 -48
- package/src/api/src/routes/knowledge.ts +30 -0
- package/src/api/src/routes/operations.ts +48 -11
- package/src/api/src/routes/sessions.ts +1 -1
- package/src/api/src/routes/setup.ts +68 -1
- package/src/api/src/routes/speech.ts +334 -143
- package/src/api/src/services/activity-stream.ts +1 -1
- package/src/api/src/services/job-manager.ts +185 -9
- package/src/api/src/services/port-registry.ts +222 -0
- package/src/api/src/services/scaffold.ts +90 -0
- package/src/api/src/services/session-store.ts +75 -5
- package/src/web/dist/assets/index-BDLT2xew.js +109 -0
- package/src/web/dist/assets/index-LvS5V8aP.css +1 -0
- package/src/web/dist/assets/{preview-app-qIFqrb-y.js → preview-app-AJtyaM6L.js} +1 -1
- package/src/web/dist/index.html +2 -2
- package/templates/skills/_manifest.json +6 -0
- package/templates/skills/agent-browser/SKILL.md +159 -0
- package/templates/skills/agent-browser/meta.json +19 -0
- package/templates/teams/agency.json +3 -3
- package/templates/teams/research.json +3 -3
- package/templates/teams/startup.json +3 -3
- package/src/web/dist/assets/index-B3dNhn76.js +0 -101
- 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
|
-
//
|
|
223
|
+
// Knowledge Gate: 새 .md 파일 생성 시 자동 검색 + 경고 (journal 제외)
|
|
223
224
|
if (isNewFile && filePath.endsWith('.md') && !filePath.includes('journal/')) {
|
|
224
|
-
result +=
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
|
333
|
-
|
|
334
|
-
|
|
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
|
|
338
|
-
const
|
|
427
|
+
// Collect child jobs (dispatched sub-roles)
|
|
428
|
+
const childJobs: WaveRoleData['childJobs'] = [];
|
|
339
429
|
for (const e of events) {
|
|
340
|
-
if (e.type === '
|
|
341
|
-
|
|
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
|
-
|
|
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
|
|
373
|
-
const
|
|
374
|
-
|
|
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/${
|
|
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
|
-
|
|
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, {
|
|
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 ───────────────────── */
|