workhorse-ai-mcp 0.7.2

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/mcp/server.mjs ADDED
@@ -0,0 +1,929 @@
1
+ #!/usr/bin/env node
2
+ // workhorse-mcp — MCP-сервер журнала делегирования оркестратор ↔ рабочая лошадка.
3
+ // Хранилище: ~/.workhorse/workhorse.db (event sourcing: append-only events,
4
+ // проекции tasks/incidents через триггеры, FTS5). Zero deps: node:sqlite (Node >= 22.5).
5
+
6
+ import { DatabaseSync } from "node:sqlite";
7
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
8
+ import { homedir } from "node:os";
9
+ import { join, dirname } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+
12
+ import {
13
+ defaultJournalId,
14
+ inboxUrlFromSyncUrl,
15
+ loadSyncConfig,
16
+ pushJournal,
17
+ writeSyncConfig,
18
+ } from "./sync.mjs";
19
+
20
+ const DB_PATH = process.env.WORKHORSE_DB ?? join(homedir(), ".workhorse", "workhorse.db");
21
+ const SCHEMA_PATH =
22
+ process.env.WORKHORSE_SCHEMA ?? join(dirname(fileURLToPath(import.meta.url)), "..", "schema.sql");
23
+
24
+ // Чистый старт на пустой машине: директория данных создаётся сама,
25
+ // иначе node:sqlite падает CANTOPEN ещё до применения схемы.
26
+ mkdirSync(dirname(DB_PATH), { recursive: true });
27
+
28
+ const db = new DatabaseSync(DB_PATH);
29
+ const hasEvents = db
30
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='events'")
31
+ .get();
32
+ if (!hasEvents) {
33
+ if (!existsSync(SCHEMA_PATH)) {
34
+ console.error(`workhorse-mcp: база пуста, а схема не найдена: ${SCHEMA_PATH}`);
35
+ process.exit(1);
36
+ }
37
+ db.exec(readFileSync(SCHEMA_PATH, "utf8"));
38
+ }
39
+
40
+ // Допустимые переходы: событие → из каких статусов его можно применить.
41
+ const VALID_FROM = {
42
+ Delegated: ["DRAFT", "REWORK"],
43
+ ReportSubmitted: ["DELEGATED"],
44
+ Accepted: ["REPORTED"],
45
+ ReworkRequested: ["REPORTED"],
46
+ Failed: ["DELEGATED", "REPORTED", "REWORK"],
47
+ };
48
+
49
+ const getTaskStmt = db.prepare("SELECT * FROM tasks WHERE task_id = ?");
50
+ const insertEventStmt = db.prepare(
51
+ "INSERT INTO events(task_id, type, payload) VALUES (?, ?, ?)",
52
+ );
53
+
54
+ // Авто-пуш синка: fire-and-forget после успешной записи события. НИКОГДА не
55
+ // блокирует и не роняет запись: нет конфига — молчаливый no-op, ошибка/офлайн —
56
+ // одна строка в stderr (курсор просто отстаёт до следующего пуша).
57
+ let syncInFlight = false;
58
+ let syncQueued = false;
59
+ function scheduleAutoPush() {
60
+ if (syncInFlight) {
61
+ syncQueued = true;
62
+ return;
63
+ }
64
+ syncInFlight = true;
65
+ setImmediate(async () => {
66
+ try {
67
+ const config = loadSyncConfig({ dbPath: DB_PATH });
68
+ if (config) {
69
+ const result = await pushJournal({ dbPath: DB_PATH, config });
70
+ if (result.error) {
71
+ console.error(`workhorse-mcp: авто-пуш синка не прошёл: ${result.error}`);
72
+ }
73
+ }
74
+ } catch (err) {
75
+ console.error(`workhorse-mcp: авто-пуш синка не прошёл: ${err.message}`);
76
+ } finally {
77
+ syncInFlight = false;
78
+ if (syncQueued) {
79
+ syncQueued = false;
80
+ scheduleAutoPush();
81
+ }
82
+ }
83
+ });
84
+ }
85
+
86
+ // Общая точка записи события: вставка + авто-пуш (только после успешной вставки).
87
+ function appendEvent(taskId, type, payloadJson) {
88
+ insertEventStmt.run(taskId, type, payloadJson);
89
+ scheduleAutoPush();
90
+ }
91
+
92
+ function emit(taskId, type, payload) {
93
+ const guard = VALID_FROM[type];
94
+ if (guard) {
95
+ const task = getTaskStmt.get(taskId);
96
+ if (!task) throw new Error(`Задача ${taskId} не найдена`);
97
+ if (!guard.includes(task.status)) {
98
+ throw new Error(
99
+ `${type} недопустим из статуса ${task.status} (допустимо из: ${guard.join(", ")})`,
100
+ );
101
+ }
102
+ }
103
+ appendEvent(taskId, type, JSON.stringify(payload ?? {}));
104
+ return getTaskStmt.get(taskId);
105
+ }
106
+
107
+ // Префиксный матч каждого токена: русская морфология без стеммера —
108
+ // «стандарт» находит «стандарты/стандартов», «ранг» — «ранги».
109
+ function ftsExpr(query) {
110
+ return query
111
+ .split(/\s+/)
112
+ .filter(Boolean)
113
+ .map((t) => `"${t.replaceAll('"', '""')}"*`)
114
+ .join(" OR ");
115
+ }
116
+
117
+ const getProjectStmt = db.prepare("SELECT * FROM projects WHERE name = ?");
118
+
119
+ // Нормализация имени проекта для поиска почти-дублей: dompro ~ dom-pro ~ DomPro
120
+ function normalizeProjectName(name) {
121
+ return name.toLowerCase().replaceAll("-", "");
122
+ }
123
+
124
+ // Путь с гарантированным завершающим слэшем — для честного prefix-сравнения
125
+ function withSlash(p) {
126
+ return p.endsWith("/") ? p : `${p}/`;
127
+ }
128
+
129
+ // Пересечение корней: равенство, вложенность в любую сторону
130
+ function pathsOverlap(a, b) {
131
+ const A = withSlash(a);
132
+ const B = withSlash(b);
133
+ return A === B || A.startsWith(B) || B.startsWith(A);
134
+ }
135
+
136
+ function findProjectConflicts(name, root_path) {
137
+ const all = db.prepare("SELECT name, root_path FROM projects").all();
138
+ const norm = normalizeProjectName(name);
139
+ return all.filter(
140
+ (p) =>
141
+ p.name !== name &&
142
+ (normalizeProjectName(p.name) === norm || pathsOverlap(p.root_path, root_path)),
143
+ );
144
+ }
145
+
146
+ function requireProject(name) {
147
+ const p = getProjectStmt.get(name);
148
+ if (!p) {
149
+ throw new Error(
150
+ `Проект "${name}" не зарегистрирован — сначала register_project (list_projects покажет существующие)`,
151
+ );
152
+ }
153
+ return p;
154
+ }
155
+
156
+ function today() {
157
+ const d = new Date();
158
+ const mm = String(d.getMonth() + 1).padStart(2, "0");
159
+ const dd = String(d.getDate()).padStart(2, "0");
160
+ return `${d.getFullYear()}-${mm}-${dd}`;
161
+ }
162
+
163
+ // ============ инструменты ============
164
+
165
+ const TOOLS = [
166
+ {
167
+ name: "search_precedents",
168
+ description:
169
+ "ОБЯЗАТЕЛЬНО перед постановкой новой задачи: полнотекстовый поиск похожих задач " +
170
+ "(задания + отчёты) по всем проектам. Возвращает совпавшие задачи и релевантные инциденты.",
171
+ inputSchema: {
172
+ type: "object",
173
+ properties: {
174
+ query: { type: "string", description: "Ключевые слова (любой язык)" },
175
+ limit: { type: "number", description: "Максимум задач (default 5)" },
176
+ },
177
+ required: ["query"],
178
+ },
179
+ handler: ({ query, limit = 5 }) => {
180
+ const expr = ftsExpr(query);
181
+ const tasks = expr
182
+ ? db
183
+ .prepare(
184
+ `SELECT t.task_id, t.project, t.title, t.status, t.outcome, t.verify_commit,
185
+ snippet(task_fts, 1, '[', ']', '…', 12) AS task_snippet,
186
+ snippet(task_fts, 2, '[', ']', '…', 12) AS report_snippet
187
+ FROM task_fts JOIN tasks t ON t.rowid = task_fts.rowid
188
+ WHERE task_fts MATCH ? ORDER BY rank LIMIT ?`,
189
+ )
190
+ .all(expr, limit)
191
+ : [];
192
+ const words = query.split(/\s+/).filter(Boolean);
193
+ const cond = words.map(() => "(description LIKE ? OR lesson LIKE ?)").join(" OR ");
194
+ const args = words.flatMap((w) => [`%${w}%`, `%${w}%`]);
195
+ const incidents = words.length
196
+ ? db
197
+ .prepare(
198
+ `SELECT task_id, description, lesson, at FROM incidents WHERE ${cond} ORDER BY at DESC LIMIT 10`,
199
+ )
200
+ .all(...args)
201
+ : [];
202
+ const artifacts = expr
203
+ ? db
204
+ .prepare(
205
+ `SELECT a.id, a.project, a.kind, a.title, a.task_id, a.at,
206
+ snippet(artifact_fts, 1, '[', ']', '…', 12) AS body_snippet
207
+ FROM artifact_fts JOIN artifacts a ON a.id = artifact_fts.rowid
208
+ WHERE artifact_fts MATCH ? ORDER BY rank LIMIT ?`,
209
+ )
210
+ .all(expr, limit)
211
+ : [];
212
+ return { tasks, artifacts, incidents };
213
+ },
214
+ },
215
+ {
216
+ name: "register_project",
217
+ description:
218
+ "Зарегистрировать проект (событие ProjectRegistered): имя = неймспейс задач, root_path = " +
219
+ "привязка к папке на диске, planado_workspace_id = маппинг на пространство planado (опционально, позже). " +
220
+ "Повторная регистрация обновляет путь/маппинг. Задачи и артефакты принимаются только для зарегистрированных проектов.",
221
+ inputSchema: {
222
+ type: "object",
223
+ properties: {
224
+ name: { type: "string", description: "Короткое имя (латиница/цифры/дефисы), напр. dom-pro" },
225
+ root_path: { type: "string", description: "Абсолютный путь к корню проекта" },
226
+ planado_workspace_id: { type: "string", description: "Id пространства planado (когда появится)" },
227
+ force: {
228
+ type: "boolean",
229
+ description: "Осознанно зарегистрировать несмотря на похожий проект (guard от почти-дублей)",
230
+ },
231
+ },
232
+ required: ["name", "root_path"],
233
+ },
234
+ handler: ({ name, root_path, planado_workspace_id, force }) => {
235
+ if (!/^[a-z0-9-]+$/.test(name)) {
236
+ throw new Error("Имя проекта: латиница/цифры/дефисы — оно входит в task_id как неймспейс");
237
+ }
238
+ // Guard от почти-дублей: похожее имя или пересекающийся корень.
239
+ // Точное совпадение имени — легальная перерегистрация (обновление пути).
240
+ if (!force) {
241
+ const conflicts = findProjectConflicts(name, root_path);
242
+ if (conflicts.length > 0) {
243
+ const listing = conflicts
244
+ .map((p) => `"${p.name}" (${p.root_path})`)
245
+ .join(", ");
246
+ throw new Error(
247
+ `Похоже, проект уже зарегистрирован: ${listing}. ` +
248
+ "Используй существующее имя (resolve_project найдёт проект по пути), " +
249
+ "либо повтори с force: true, если это осознанно отдельный проект.",
250
+ );
251
+ }
252
+ }
253
+ appendEvent(
254
+ "_general",
255
+ "ProjectRegistered",
256
+ JSON.stringify({ name, root_path, planado_workspace_id: planado_workspace_id ?? null }),
257
+ );
258
+ return getProjectStmt.get(name);
259
+ },
260
+ },
261
+ {
262
+ name: "resolve_project",
263
+ description:
264
+ "Найти уже зарегистрированный проект ПЕРЕД register_project/бутстрапом: по пути (какой проект " +
265
+ "покрывает эту папку) и/или по имени (похожие имена, dompro ~ dom-pro). Пустой ответ = проекта нет.",
266
+ inputSchema: {
267
+ type: "object",
268
+ properties: {
269
+ path: { type: "string", description: "Абсолютный путь (папка проекта или файла внутри него)" },
270
+ name: { type: "string", description: "Предполагаемое имя проекта" },
271
+ },
272
+ },
273
+ handler: ({ path, name }) => {
274
+ const all = db.prepare("SELECT name, root_path, planado_workspace_id, at FROM projects").all();
275
+ const byPath = path
276
+ ? all.filter((p) => withSlash(path).startsWith(withSlash(p.root_path)) || pathsOverlap(p.root_path, path))
277
+ : [];
278
+ const norm = name ? normalizeProjectName(name) : null;
279
+ const byName = norm
280
+ ? all.filter(
281
+ (p) =>
282
+ normalizeProjectName(p.name) === norm ||
283
+ normalizeProjectName(p.name).includes(norm) ||
284
+ norm.includes(normalizeProjectName(p.name)),
285
+ )
286
+ : [];
287
+ const seen = new Set();
288
+ const matches = [...byPath, ...byName].filter((p) => !seen.has(p.name) && seen.add(p.name));
289
+ return { matches };
290
+ },
291
+ },
292
+ {
293
+ name: "list_projects",
294
+ description: "Реестр проектов: имя, путь на диске, маппинг на пространство planado.",
295
+ inputSchema: { type: "object", properties: {} },
296
+ handler: () =>
297
+ db.prepare("SELECT name, root_path, planado_workspace_id, at FROM projects ORDER BY name").all(),
298
+ },
299
+ {
300
+ name: "draft_task",
301
+ description:
302
+ "Поставить задачу в журнал (событие TaskDrafted). Полный текст задания — в task_text: " +
303
+ "контекст, готовый root cause, порядок работ (TDD, базовая цифра тестов), запреты, формат отчёта.",
304
+ inputSchema: {
305
+ type: "object",
306
+ properties: {
307
+ project: { type: "string", description: "Имя проекта, напр. dom-pro" },
308
+ slug: { type: "string", description: "Короткий латинский слаг, напр. gantt-ranks" },
309
+ title: { type: "string", description: "Человекочитаемое название" },
310
+ task_text: { type: "string", description: "Полный текст задания для исполнителя" },
311
+ task_id: {
312
+ type: "string",
313
+ description: "Явный id (иначе соберётся как <project>/<дата>-<slug>)",
314
+ },
315
+ intent_task_id: {
316
+ type: "string",
317
+ description:
318
+ "Id облачного Task-намерения (из take): связка journal→intent едет в payload события",
319
+ },
320
+ },
321
+ required: ["project", "title", "task_text"],
322
+ },
323
+ handler: ({ project, slug, title, task_text, task_id, intent_task_id }) => {
324
+ if (!task_id) {
325
+ if (!slug || !/^[a-z0-9-]+$/.test(slug)) {
326
+ throw new Error("Нужен slug (латиница/цифры/дефисы) или явный task_id");
327
+ }
328
+ task_id = `${project}/${today()}-${slug}`;
329
+ } else if (!task_id.startsWith(`${project}/`)) {
330
+ throw new Error(
331
+ `task_id должен начинаться с "${project}/" — неймспейс проекта входит в id`,
332
+ );
333
+ }
334
+ requireProject(project);
335
+ const existing = getTaskStmt.get(task_id);
336
+ if (existing && !["DRAFT", "REWORK"].includes(existing.status)) {
337
+ throw new Error(
338
+ `Задача ${task_id} уже в статусе ${existing.status} — редактировать нельзя, заведи новую`,
339
+ );
340
+ }
341
+ // Связка с намерением — свободный payload события: схема БД не меняется,
342
+ // облачный транслятор прочитает intent_task_id при материализации.
343
+ return emit(task_id, "TaskDrafted", {
344
+ project,
345
+ title,
346
+ task_text,
347
+ ...(intent_task_id ? { intent_task_id } : {}),
348
+ });
349
+ },
350
+ },
351
+ {
352
+ name: "delegate",
353
+ description:
354
+ "Отметить делегацию задачи исполнителю (событие Delegated). Допустимо из DRAFT или REWORK. " +
355
+ "Сам запуск исполнителя — отдельно; в его промпт включить текст задания и путь файла для отчёта.",
356
+ inputSchema: {
357
+ type: "object",
358
+ properties: {
359
+ task_id: { type: "string" },
360
+ executor: { type: "string", description: "grok / codex / subagent / inline" },
361
+ },
362
+ required: ["task_id", "executor"],
363
+ },
364
+ handler: ({ task_id, executor }) => emit(task_id, "Delegated", { executor }),
365
+ },
366
+ {
367
+ name: "submit_report",
368
+ description: "Записать отчёт исполнителя (событие ReportSubmitted). Допустимо из DELEGATED.",
369
+ inputSchema: {
370
+ type: "object",
371
+ properties: {
372
+ task_id: { type: "string" },
373
+ report: { type: "string", description: "Полный текст отчёта исполнителя" },
374
+ },
375
+ required: ["task_id", "report"],
376
+ },
377
+ handler: ({ task_id, report }) => emit(task_id, "ReportSubmitted", { report }),
378
+ },
379
+ {
380
+ name: "accept",
381
+ description:
382
+ "Принять задачу (событие Accepted). ТОЛЬКО после собственной верификации: дифф построчно, " +
383
+ "своя сборка, полный прогон тестов, коммит. Допустимо из REPORTED.",
384
+ inputSchema: {
385
+ type: "object",
386
+ properties: {
387
+ task_id: { type: "string" },
388
+ verify_commit: { type: "string", description: "Хэш коммита, которым принята работа" },
389
+ outcome: { type: "string", description: "accepted (default) | accepted-with-fixes" },
390
+ },
391
+ required: ["task_id", "verify_commit"],
392
+ },
393
+ handler: ({ task_id, verify_commit, outcome = "accepted" }) =>
394
+ emit(task_id, "Accepted", { outcome, verify_commit }),
395
+ },
396
+ {
397
+ name: "request_rework",
398
+ description:
399
+ "Вернуть задачу на доработку (событие ReworkRequested), из REPORTED. Дальше — новая delegate.",
400
+ inputSchema: {
401
+ type: "object",
402
+ properties: {
403
+ task_id: { type: "string" },
404
+ reason: { type: "string", description: "Что именно не так (по итогам верификации)" },
405
+ },
406
+ required: ["task_id", "reason"],
407
+ },
408
+ handler: ({ task_id, reason }) => emit(task_id, "ReworkRequested", { reason }),
409
+ },
410
+ {
411
+ name: "mark_failed",
412
+ description: "Закрыть задачу как проваленную (событие Failed), из DELEGATED/REPORTED/REWORK.",
413
+ inputSchema: {
414
+ type: "object",
415
+ properties: {
416
+ task_id: { type: "string" },
417
+ reason: { type: "string" },
418
+ },
419
+ required: ["task_id", "reason"],
420
+ },
421
+ handler: ({ task_id, reason }) => emit(task_id, "Failed", { reason }),
422
+ },
423
+ {
424
+ name: "record_incident",
425
+ description:
426
+ "Записать грабли/урок (событие IncidentRecorded). Для инцидентов вне конкретной задачи " +
427
+ "task_id можно опустить. Повторяющийся урок — продублировать в таблицу граблей скилла.",
428
+ inputSchema: {
429
+ type: "object",
430
+ properties: {
431
+ description: { type: "string", description: "Что случилось" },
432
+ lesson: { type: "string", description: "Как обходить впредь" },
433
+ task_id: { type: "string" },
434
+ },
435
+ required: ["description", "lesson"],
436
+ },
437
+ handler: ({ description, lesson, task_id = "_general" }) => {
438
+ appendEvent(task_id, "IncidentRecorded", JSON.stringify({ description, lesson }));
439
+ return { recorded: true, task_id, description, lesson };
440
+ },
441
+ },
442
+ {
443
+ name: "link_tasks",
444
+ description:
445
+ "Связать задачи (событие TaskLinked, направленно from → to). kind: continues " +
446
+ "(«from — продолжение to»; для продолжения заводи НОВУЮ задачу и линкуй на старую), " +
447
+ "relates, blocks («from блокирует to»), discovered_from («from найдена в ходе to»).",
448
+ inputSchema: {
449
+ type: "object",
450
+ properties: {
451
+ from_task_id: { type: "string" },
452
+ to_task_id: { type: "string" },
453
+ kind: { type: "string", description: "continues | relates | blocks | discovered_from" },
454
+ },
455
+ required: ["from_task_id", "to_task_id", "kind"],
456
+ },
457
+ handler: ({ from_task_id, to_task_id, kind }) => {
458
+ if (!["continues", "relates", "blocks", "discovered_from"].includes(kind)) {
459
+ throw new Error("kind: continues | relates | blocks | discovered_from");
460
+ }
461
+ if (from_task_id === to_task_id) throw new Error("Задача не может ссылаться на себя");
462
+ for (const id of [from_task_id, to_task_id]) {
463
+ if (!getTaskStmt.get(id)) throw new Error(`Задача ${id} не найдена`);
464
+ }
465
+ appendEvent(from_task_id, "TaskLinked", JSON.stringify({ to_task_id, kind }));
466
+ return db
467
+ .prepare("SELECT from_task, to_task, kind, at FROM task_links WHERE from_task = ?")
468
+ .all(from_task_id);
469
+ },
470
+ },
471
+ {
472
+ name: "record_artifact",
473
+ description:
474
+ "Зафиксировать артефакт: спеку, план, ADR, решение или заметку (событие ArtifactRecorded). " +
475
+ "Спека пишется ДО делегации; повторная запись с тем же title = новая версия (старая остаётся в истории).",
476
+ inputSchema: {
477
+ type: "object",
478
+ properties: {
479
+ project: { type: "string" },
480
+ kind: { type: "string", description: "spec | plan | adr | decision | note | doc" },
481
+ title: { type: "string" },
482
+ body: { type: "string", description: "Полный текст артефакта (markdown)" },
483
+ task_id: { type: "string", description: "Опциональная привязка к задаче журнала" },
484
+ },
485
+ required: ["project", "kind", "title", "body"],
486
+ },
487
+ handler: ({ project, kind, title, body, task_id = "_general" }) => {
488
+ if (!["spec", "plan", "adr", "decision", "note", "doc"].includes(kind)) {
489
+ throw new Error("kind: spec | plan | adr | decision | note | doc");
490
+ }
491
+ requireProject(project);
492
+ appendEvent(task_id, "ArtifactRecorded", JSON.stringify({ project, kind, title, body }));
493
+ return db
494
+ .prepare("SELECT id, task_id, project, kind, title, at FROM artifacts ORDER BY id DESC LIMIT 1")
495
+ .get();
496
+ },
497
+ },
498
+ {
499
+ name: "get_artifact",
500
+ description: "Полный текст артефакта по id (id даёт search_precedents / list_artifacts).",
501
+ inputSchema: {
502
+ type: "object",
503
+ properties: { id: { type: "number" } },
504
+ required: ["id"],
505
+ },
506
+ handler: ({ id }) => {
507
+ const a = db.prepare("SELECT * FROM artifacts WHERE id = ?").get(id);
508
+ if (!a) throw new Error(`Артефакт ${id} не найден`);
509
+ return a;
510
+ },
511
+ },
512
+ {
513
+ name: "list_artifacts",
514
+ description: "Список артефактов с фильтрами по проекту/типу/задаче (без тела — только заголовки).",
515
+ inputSchema: {
516
+ type: "object",
517
+ properties: {
518
+ project: { type: "string" },
519
+ kind: { type: "string", description: "spec | plan | adr | decision | note | doc" },
520
+ task_id: { type: "string" },
521
+ limit: { type: "number", description: "default 20" },
522
+ },
523
+ },
524
+ handler: ({ project, kind, task_id, limit = 20 }) => {
525
+ const cond = [];
526
+ const args = [];
527
+ if (project) { cond.push("project = ?"); args.push(project); }
528
+ if (kind) { cond.push("kind = ?"); args.push(kind); }
529
+ if (task_id) { cond.push("task_id = ?"); args.push(task_id); }
530
+ const where = cond.length ? `WHERE ${cond.join(" AND ")}` : "";
531
+ return db
532
+ .prepare(`SELECT id, task_id, project, kind, title, at FROM artifacts ${where} ORDER BY id DESC LIMIT ?`)
533
+ .all(...args, limit);
534
+ },
535
+ },
536
+ {
537
+ name: "connect",
538
+ description:
539
+ "Подключить журнал к облаку planado: проверяет связь (GET курсора с токеном) и при успехе " +
540
+ "сам пишет sync.json рядом с базой — ручная настройка не нужна. journal_id по умолчанию " +
541
+ "собирается из имени пользователя и машины. Повторный connect перезаписывает конфиг " +
542
+ "(осознанная смена воркспейса/токена). При ошибке связи конфиг не трогается.",
543
+ inputSchema: {
544
+ type: "object",
545
+ properties: {
546
+ url: {
547
+ type: "string",
548
+ description: "URL синка воркспейса, напр. https://app.planado.ru/api/mcp/journal-sync",
549
+ },
550
+ token: { type: "string", description: "MCP-токен воркспейса (Bearer)" },
551
+ journal_id: {
552
+ type: "string",
553
+ description: "Имя журнала (default: <username>-<hostname>, нормализованное)",
554
+ },
555
+ },
556
+ required: ["url", "token"],
557
+ },
558
+ handler: async ({ url, token, journal_id }) => {
559
+ const journalId = journal_id ?? defaultJournalId();
560
+ if (!/^[a-z0-9-]+$/.test(journalId)) {
561
+ return `не подключено: journal_id «${journalId}» — допустимы только строчные латинские буквы, цифры и дефисы`;
562
+ }
563
+ let cursorUrl;
564
+ try {
565
+ cursorUrl = new URL(url);
566
+ } catch {
567
+ return `не подключено: некорректный url «${url}»`;
568
+ }
569
+ cursorUrl.searchParams.set("journalId", journalId);
570
+ let res;
571
+ try {
572
+ res = await fetch(cursorUrl, {
573
+ headers: { authorization: `Bearer ${token}` },
574
+ });
575
+ } catch (err) {
576
+ return `не подключено: облако недоступно (${err.cause?.code ?? err.message})`;
577
+ }
578
+ if (!res.ok) {
579
+ const reason =
580
+ res.status === 401
581
+ ? "токен не принят (невалидный или отозванный)"
582
+ : res.status === 403
583
+ ? "доступ запрещён"
584
+ : "облако отвергло запрос";
585
+ return `не подключено: HTTP ${res.status} — ${reason}. Конфиг не записан.`;
586
+ }
587
+ let lastSeq;
588
+ try {
589
+ ({ lastSeq } = await res.json());
590
+ } catch {
591
+ return "не подключено: облако ответило не-JSON (это точно URL journal-sync?). Конфиг не записан.";
592
+ }
593
+ if (!Number.isInteger(lastSeq) || lastSeq < 0) {
594
+ return `не подключено: облако вернуло некорректный lastSeq (${lastSeq}). Конфиг не записан.`;
595
+ }
596
+ const configPath = writeSyncConfig({
597
+ dbPath: DB_PATH,
598
+ config: { url, token, journalId },
599
+ });
600
+ scheduleAutoPush();
601
+ return `подключено: курсор ${lastSeq}, журнал ${journalId}, конфиг ${configPath}`;
602
+ },
603
+ },
604
+ {
605
+ name: "sync",
606
+ description:
607
+ "Принудительно отправить журнал в облако planado (push, только вверх): GET курсор → " +
608
+ "POST события с seq > курсора. Конфиг: sync.json рядом с базой (url, token, journalId) " +
609
+ "либо env WORKHORSE_SYNC_URL/TOKEN/JOURNAL_ID. Без конфига синк выключен.",
610
+ inputSchema: { type: "object", properties: {} },
611
+ handler: async () => {
612
+ const config = loadSyncConfig({ dbPath: DB_PATH });
613
+ if (!config) return "синк не настроен (нет sync.json)";
614
+ const result = await pushJournal({ dbPath: DB_PATH, config });
615
+ if (result.error) return `ошибка синка: ${result.error}`;
616
+ return `отправлено ${result.pushed} событий, курсор ${result.lastSeq}`;
617
+ },
618
+ },
619
+ {
620
+ name: "inbox",
621
+ description:
622
+ "Инбокс намерений из облака planado (pull: облако только отдаёт список, забирает оркестратор). " +
623
+ "Показывает предложенные Task-намерения с контекстом фичи. Конфиг — тот же, что у синка " +
624
+ "(sync.json / env), URL инбокса выводится из url синка. Забрать намерение — инструмент take.",
625
+ inputSchema: { type: "object", properties: {} },
626
+ handler: async () => {
627
+ const config = loadSyncConfig({ dbPath: DB_PATH });
628
+ if (!config) return "инбокс не настроен (нет sync.json)";
629
+ if (!config.url || !config.token) {
630
+ return "инбокс не настроен: в конфиге синка нужны url и token";
631
+ }
632
+ try {
633
+ const res = await fetch(inboxUrlFromSyncUrl(config.url), {
634
+ headers: { authorization: `Bearer ${config.token}` },
635
+ });
636
+ if (!res.ok) return `ошибка инбокса: HTTP ${res.status}`;
637
+ const { items } = await res.json();
638
+ if (!Array.isArray(items) || items.length === 0) {
639
+ return "инбокс пуст: 0 намерений";
640
+ }
641
+ const lines = items.map(
642
+ (item) =>
643
+ `- ${item.id} — ${item.title} (фича: ${item.feature?.title ?? "?"})`,
644
+ );
645
+ return `${items.length} намерений:\n${lines.join("\n")}\n\nЗабрать: take { task_id: <id> }.`;
646
+ } catch (err) {
647
+ return `ошибка инбокса: ${err.message}`;
648
+ }
649
+ },
650
+ },
651
+ {
652
+ name: "take",
653
+ description:
654
+ "Забрать намерение из инбокса облака (идемпотентно: повторный take вернёт тот же контекст). " +
655
+ "Отвечает готовой болванкой для draft_task: контекст фичи, текст намерения, intent_task_id. " +
656
+ "Дальше обычный цикл: search_precedents → draft_task (с intent_task_id) → delegate → …",
657
+ inputSchema: {
658
+ type: "object",
659
+ properties: {
660
+ task_id: { type: "string", description: "Id облачного Task-намерения (из inbox)" },
661
+ },
662
+ required: ["task_id"],
663
+ },
664
+ handler: async ({ task_id }) => {
665
+ const config = loadSyncConfig({ dbPath: DB_PATH });
666
+ if (!config) return "инбокс не настроен (нет sync.json)";
667
+ if (!config.url || !config.token) {
668
+ return "инбокс не настроен: в конфиге синка нужны url и token";
669
+ }
670
+ try {
671
+ const res = await fetch(inboxUrlFromSyncUrl(config.url), {
672
+ method: "POST",
673
+ headers: {
674
+ authorization: `Bearer ${config.token}`,
675
+ "content-type": "application/json",
676
+ },
677
+ body: JSON.stringify({ taskId: task_id }),
678
+ });
679
+ if (res.status === 404) return `намерение ${task_id} не найдено в облаке`;
680
+ if (!res.ok) return `ошибка take: HTTP ${res.status}`;
681
+ const { alreadyTaken, context } = await res.json();
682
+ const feature = context.feature ?? {};
683
+ return [
684
+ alreadyTaken
685
+ ? `Намерение уже было забрано ранее — повторная выдача контекста (take идемпотентен).`
686
+ : `Намерение забрано из инбокса.`,
687
+ ``,
688
+ `intent_task_id: ${context.id}`,
689
+ ``,
690
+ `## Контекст фичи: ${feature.title ?? "?"}`,
691
+ feature.description ?? "(описание фичи отсутствует)",
692
+ ...(feature.clarifications ? [``, `Уточнения: ${feature.clarifications}`] : []),
693
+ ``,
694
+ `## Намерение: ${context.title}`,
695
+ context.description ?? "(описания нет — уточни постановку сам)",
696
+ ``,
697
+ `Рекомендованный slug: ${context.recommendedSlug}`,
698
+ ``,
699
+ `Дальше: search_precedents по теме → draft_task с intent_task_id: "${context.id}" ` +
700
+ `(project, slug, title, полный task_text по правилам журнала) → delegate → обычный цикл.`,
701
+ ].join("\n");
702
+ } catch (err) {
703
+ return `ошибка take: ${err.message}`;
704
+ }
705
+ },
706
+ },
707
+ {
708
+ name: "get_task",
709
+ description: "Текущее состояние задачи + полная история её событий.",
710
+ inputSchema: {
711
+ type: "object",
712
+ properties: { task_id: { type: "string" } },
713
+ required: ["task_id"],
714
+ },
715
+ handler: ({ task_id }) => {
716
+ const task = getTaskStmt.get(task_id);
717
+ if (!task) throw new Error(`Задача ${task_id} не найдена`);
718
+ const events = db
719
+ .prepare("SELECT seq, type, payload, at FROM events WHERE task_id = ? ORDER BY seq")
720
+ .all(task_id);
721
+ const links = {
722
+ outgoing: db
723
+ .prepare("SELECT to_task, kind FROM task_links WHERE from_task = ?")
724
+ .all(task_id),
725
+ incoming: db
726
+ .prepare("SELECT from_task, kind FROM task_links WHERE to_task = ?")
727
+ .all(task_id),
728
+ };
729
+ const artifacts = db
730
+ .prepare("SELECT id, kind, title, at FROM artifacts WHERE task_id = ?")
731
+ .all(task_id);
732
+ return { task, events, links, artifacts };
733
+ },
734
+ },
735
+ {
736
+ name: "list_tasks",
737
+ description: "Список задач журнала с фильтрами по статусу/проекту.",
738
+ inputSchema: {
739
+ type: "object",
740
+ properties: {
741
+ status: {
742
+ type: "string",
743
+ description: "DRAFT | DELEGATED | REPORTED | ACCEPTED | REWORK | FAILED",
744
+ },
745
+ project: { type: "string" },
746
+ limit: { type: "number", description: "default 20" },
747
+ },
748
+ },
749
+ handler: ({ status, project, limit = 20 }) => {
750
+ const cond = [];
751
+ const args = [];
752
+ if (status) { cond.push("status = ?"); args.push(status); }
753
+ if (project) { cond.push("project = ?"); args.push(project); }
754
+ const where = cond.length ? `WHERE ${cond.join(" AND ")}` : "";
755
+ return db
756
+ .prepare(
757
+ `SELECT task_id, project, title, status, outcome, executor, updated_at
758
+ FROM tasks ${where} ORDER BY updated_at DESC LIMIT ?`,
759
+ )
760
+ .all(...args, limit);
761
+ },
762
+ },
763
+ ];
764
+
765
+ // ============ инструкция сервера и промпты (слеш-команды) ============
766
+
767
+ const SERVER_INSTRUCTIONS = `Журнал делегирования оркестратор ↔ рабочая лошадка (event sourcing поверх SQLite).
768
+ Статусы: DRAFT → DELEGATED → REPORTED → ACCEPTED | REWORK (→ DELEGATED…) | FAILED.
769
+ Ключевой инвариант: REPORTED («исполнитель считает, что готово») ≠ ACCEPTED («оркестратор верифицировал»).
770
+ Проекты регистрируются в реестре (register_project: имя-неймспейс + root_path + маппинг на пространство planado); задачи и артефакты принимаются только для зарегистрированных. Проект без workhorse — просто не регистрируй.
771
+ ЖЁСТКОЕ PRECONDITION: без явного bootstrap'а (регистрация + артефакт "Project baseline: <project>" с базовой цифрой полного прогона тестов) на проекте не работать — ни draft_task, ни делегаций. Нет baseline — сначала промпт bootstrap, без исключений для «маленьких задач».
772
+ Порядок: search_precedents (ОБЯЗАТЕЛЬНО до постановки) → record_artifact для спеки/плана/решения, если они рождались в обсуждении → draft_task (полный текст задания: контекст, root cause, порядок работ, запреты, формат отчёта) → delegate + запуск исполнителя (отчёт он пишет в файл, не в stdout) → submit_report → собственная верификация (дифф построчно, свой полный прогон тестов) → accept (только после зелёного прогона, с verify_commit) | request_rework | mark_failed → record_incident, если были грабли.
773
+ Артефакты (spec/plan/adr/decision/note) — версионируемые: повторная запись с тем же title = новая версия. Значимое проектное решение из обсуждения фиксируется артефактом СРАЗУ — иначе оно умрёт с сессией.
774
+ Продолжение закрытой задачи — всегда НОВАЯ задача + link_tasks kind=continues на старую (rework-цикл только внутри незакрытой). Найденная по ходу работа — новая задача + discovered_from.
775
+ Запись в журнале — информация; доказательство приёмки — собственный прогон оркестратора.`;
776
+
777
+ const PROMPTS = [
778
+ {
779
+ name: "tasks",
780
+ description: "Показать журнал делегирования: задачи, поиск, инциденты",
781
+ arguments: [
782
+ {
783
+ name: "filter",
784
+ description:
785
+ "Пусто = последние задачи; статус / проект / task_id / 'incidents' / ключевые слова",
786
+ required: false,
787
+ },
788
+ ],
789
+ build: ({ filter = "" }) => `Покажи журнал делегирования workhorse (инструменты mcp__workhorse__*).
790
+
791
+ Фильтр: "${filter}"
792
+
793
+ Разбор фильтра: пусто — list_tasks (последние 20); статус (DRAFT/DELEGATED/REPORTED/ACCEPTED/REWORK/FAILED, регистр не важен) — list_tasks по статусу; имя проекта — list_tasks по проекту (комбинируются); "incidents" — search_precedents/база: последние грабли (description + lesson); значение с "/" — get_task: карточка + история событий; иные слова — search_precedents.
794
+
795
+ Вывод: компактная таблица task_id | статус | исполнитель | outcome | обновлено (для карточки — поля + хронология). Пусто — так и сказать, без лишнего текста.`,
796
+ },
797
+ {
798
+ name: "bootstrap",
799
+ description:
800
+ "Собрать основу (baseline) текущего проекта в журнал: стек, команды, базовая цифра тестов, запреты",
801
+ arguments: [
802
+ {
803
+ name: "project",
804
+ description: "Имя проекта (пусто — определить по текущему каталогу)",
805
+ required: false,
806
+ },
807
+ ],
808
+ build: ({ project = "" }) => `Собери основу (baseline) проекта для журнала делегирования workhorse.
809
+
810
+ Проект: "${project}" (если пусто — определи по текущему каталогу / git remote).
811
+
812
+ Шаг 0: проект должен быть в реестре — list_projects; нет — register_project (имя-неймспейс, root_path = корень проекта на диске).
813
+ Затем проверь, нет ли уже baseline: list_artifacts kind=spec по проекту, title "Project baseline: <project>". Есть и актуален — доложи и обнови только устаревшие пункты (record_artifact с тем же title = новая версия). Нет — собирай, фактами, не предположениями:
814
+
815
+ 1. Стек и структура: языки, фреймворки, ключевые каталоги; правила из CLAUDE.md / AGENTS.md / README (конвенции кода, стек-правила).
816
+ 2. Команды: сборка, точечный прогон тестов, полный прогон. Выполни полный прогон и зафиксируй базовую цифру (N passed / M total) — это эталон приёмки для всех будущих делегаций на проекте.
817
+ 3. Запреты для исполнителей: защищённые каталоги и файлы (submodules, генерённый код, чужие подсистемы), что нельзя коммитить/трогать.
818
+ 4. Особенности среды: известные флейки, осиротевшие build-серверы, специфика тулчейна.
819
+
820
+ Зафиксируй ОДНИМ артефактом: record_artifact kind=spec, title "Project baseline: <project>", body — пункты выше в markdown.
821
+
822
+ Дальше при каждой draft_task этого проекта: подтянуть baseline (list_artifacts / get_artifact) и включить в task_text базовую цифру тестов и запреты. После существенных изменений проекта (новый тулчейн, изменилась цифра тестов) — обновить baseline той же командой.`,
823
+ },
824
+ ];
825
+
826
+ // ============ JSON-RPC поверх stdio (newline-delimited) ============
827
+
828
+ function respond(id, result) {
829
+ if (id === undefined || id === null) return;
830
+ process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id, result }) + "\n");
831
+ }
832
+
833
+ function respondError(id, message, code = -32000) {
834
+ if (id === undefined || id === null) return;
835
+ process.stdout.write(
836
+ JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } }) + "\n",
837
+ );
838
+ }
839
+
840
+ function handle(msg) {
841
+ const { id, method, params } = msg;
842
+ try {
843
+ if (method === "initialize") {
844
+ respond(id, {
845
+ protocolVersion: params?.protocolVersion ?? "2024-11-05",
846
+ capabilities: { tools: {}, prompts: {} },
847
+ serverInfo: { name: "workhorse-mcp", version: "0.7.1" },
848
+ instructions: SERVER_INSTRUCTIONS,
849
+ });
850
+ } else if (method === "prompts/list") {
851
+ respond(id, {
852
+ prompts: PROMPTS.map(({ name, description, arguments: args }) => ({
853
+ name,
854
+ description,
855
+ arguments: args,
856
+ })),
857
+ });
858
+ } else if (method === "prompts/get") {
859
+ const prompt = PROMPTS.find((p) => p.name === params?.name);
860
+ if (!prompt) throw new Error(`Неизвестный промпт: ${params?.name}`);
861
+ respond(id, {
862
+ description: prompt.description,
863
+ messages: [
864
+ {
865
+ role: "user",
866
+ content: { type: "text", text: prompt.build(params?.arguments ?? {}) },
867
+ },
868
+ ],
869
+ });
870
+ } else if (method === "notifications/initialized") {
871
+ // notification — ответа не требует
872
+ } else if (method === "ping") {
873
+ respond(id, {});
874
+ } else if (method === "tools/list") {
875
+ respond(id, {
876
+ tools: TOOLS.map(({ name, description, inputSchema }) => ({
877
+ name,
878
+ description,
879
+ inputSchema,
880
+ })),
881
+ });
882
+ } else if (method === "tools/call") {
883
+ const tool = TOOLS.find((t) => t.name === params?.name);
884
+ if (!tool) throw new Error(`Неизвестный инструмент: ${params?.name}`);
885
+ // Promise.resolve поддерживает и синхронные, и async-хендлеры (sync).
886
+ // Строковый результат — готовый текст ответа, объект — JSON.
887
+ Promise.resolve()
888
+ .then(() => tool.handler(params?.arguments ?? {}))
889
+ .then((result) => {
890
+ respond(id, {
891
+ content: [
892
+ {
893
+ type: "text",
894
+ text: typeof result === "string" ? result : JSON.stringify(result, null, 2),
895
+ },
896
+ ],
897
+ });
898
+ })
899
+ .catch((err) => {
900
+ respond(id, {
901
+ content: [{ type: "text", text: `Ошибка: ${err.message}` }],
902
+ isError: true,
903
+ });
904
+ });
905
+ } else {
906
+ respondError(id, `Unsupported method: ${method}`, -32601);
907
+ }
908
+ } catch (err) {
909
+ respondError(id, err instanceof Error ? err.message : String(err));
910
+ }
911
+ }
912
+
913
+ let buffer = "";
914
+ process.stdin.setEncoding("utf8");
915
+ process.stdin.on("data", (chunk) => {
916
+ buffer += chunk;
917
+ let nl;
918
+ while ((nl = buffer.indexOf("\n")) !== -1) {
919
+ const line = buffer.slice(0, nl).trim();
920
+ buffer = buffer.slice(nl + 1);
921
+ if (!line) continue;
922
+ try {
923
+ handle(JSON.parse(line));
924
+ } catch {
925
+ console.error("workhorse-mcp: непарсимая строка входа");
926
+ }
927
+ }
928
+ });
929
+ process.stdin.on("end", () => process.exit(0));