feat(lib): bundle MCP servers from .common/lib into factory/lib/

Copies source (no node_modules, dist, .tasks, .wiki, __pycache__) for:
- projects-meta-mcp v2.25.0 (TypeScript/Node)
- wiki-graph v0.3.1 (TypeScript/Node)
- interns-mcp v0.3.3 (Python/FastMCP)

.gitignore: exclude lib build artefacts (node_modules, dist, .venv, __pycache__, *.pyc)
bootstrap.ps1: add MCP build step — npm install+build for TS servers, venv+pip for Python

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 13:17:12 +03:00
parent 9eeae13e72
commit ca669d96e1
116 changed files with 25331 additions and 0 deletions

View File

@@ -0,0 +1,269 @@
# projects-meta-mcp — Design
**Date:** 2026-04-29
**Status:** Approved (брейнсторм завершён, спека к ревью)
**Owner:** vitya
## Цель
Дать Claude (и любому MCP-клиенту) единый, дешёвый и offline-устойчивый доступ к двум видам мета-информации, разбросанным по множеству проектов и нескольких машин:
1. **Статусы задач** — свод `.tasks/STATUS.md` всех проектов в Gitea.
2. **Общие знания** — поиск и чтение страниц общей вики (`projects-wiki`), отфильтрованных по домену текущего проекта.
## Не цели (явный YAGNI)
- ❌ Удалённый MCP-процесс на Synology (HTTP/SSE-транспорт). Откладывается до момента, когда «truly always-on» станет реальной болью.
- ❌ Карантин `_pending/` для projects-wiki. Решение по промоушену принимается в моменте.
- ❌ Sparse-checkout всех проектов на каждую машину. API + кэш проще.
- ❌ Серверный агрегатор / git-hook на стороне Gitea. Клиентский sync проще.
- ❌ Embedding/семантический поиск projects-wiki. Достаточно поиска по тексту/тегам.
- ❌ Управление зависимостями между проектами, GANTT, deadlines. Только статусы.
## Архитектура
```
┌──────────────────┐
│ Gitea (Synology)│
│ - все проекты │
│ - projects-wiki │
│ - projects-meta-│
│ mcp (этот код)│
└────────┬─────────┘
│ pull / API
┌────────────────── каждая машина ─────────────────┐
│ │
│ ~/projects/.wiki/ ← git clone │
│ ~/.cache/projects-mcp/ ← JSON-кэш STATUS │
│ │
│ sync-script (cron / руками): │
│ - git pull в projects-wiki │
│ - Gitea API → собрать STATUS.md → JSON-кэш │
│ - offline → no-op, кэш остаётся │
│ │
│ mcp-server (локальный, stdio): │
│ Tools: │
│ tasks.aggregate() │
│ tasks.search(q) │
│ knowledge.search(q, domain?) │
│ knowledge.get(slug) │
│ knowledge.suggest_promote() │
└──────────────────────────────────────────────────┘
```
**Принцип:** MCP в hot path читает только локальные файлы. Сетевые вызовы — только в `sync-script`.
## Компоненты
### 1. `projects-wiki` — отдельный git-репозиторий на Gitea
**Имя репо:** `projects-wiki`. Клонируется на каждую машину под `~/projects/.wiki/`.
**Структура:**
```
~/projects/.wiki/
├── CLAUDE.md ← правила промоушена + список доменов
├── README.md
├── cross/ ← кросс-доменные знания (git, vscode, claude-code, общие гочи)
├── node/ ← Node.js / TypeScript / npm / yarn
├── embedded/ ← микроконтроллеры, PlatformIO, STM32, Arduino
├── web/ ← фронтенд, браузер, HTTP, Nginx
└── ... ← новые домены добавляются по мере появления проектов
```
**Frontmatter каждой страницы:**
```yaml
---
title: Windows yarn requires exec()
domain: node # обязательное: node | embedded | web | cross | ...
shared_at: 2026-04-29 # дата промоушена
applies_to: [windows] # опц. — платформенные ограничения
source_project: npm-mcp # опц. — откуда промоутили
---
```
Тело — стандартный concept: **Why** + **Когда / Как чинить**.
**`projects-wiki/CLAUDE.md`** содержит:
- Полный список валидных доменов и что в каждый кладётся.
- Эвристику промоушена (см. ниже).
- Чёрный список (имена проектов, бизнес-доменов — никогда не упоминаются в shared).
### 2. `sync-script`
Bash или Node-скрипт. На каждой машине, запускается cron'ом / руками / git-хуком.
**Шаги:**
1. **Pull projects-wiki:**
```
git -C ~/projects/.wiki pull --quiet
```
Offline → silently fail (exit 0), кэш не трогается.
2. **Скачать список репозиториев пользователя через Gitea API:**
```
GET https://git.kzntsv.site/api/v1/users/OpeItcLoc03/repos?limit=50
```
С токеном (в `~/.config/projects-mcp/auth.toml`).
3. **Параллельно (limit 10) скачать `STATUS.md` каждого:**
```
GET https://git.kzntsv.site/api/v1/repos/OpeItcLoc03/{repo}/raw/.tasks/STATUS.md
```
404 → у проекта нет тасок, пропустить. Сетевая ошибка → пропустить, оставить старое значение в кэше.
4. **Собрать в `~/.cache/projects-mcp/tasks.json`** атомарно (write to `tasks.json.tmp`, rename):
```json
{
"synced_at": "2026-04-29T12:00:00Z",
"synced_from": "https://git.kzntsv.site",
"machine": "vitya-desktop",
"projects": [
{
"name": "npm-mcp",
"default_branch": "main",
"fetched_at": "2026-04-29T12:00:01Z",
"active_tasks": [
{"slug": "wiki-self-ingest", "status": "active", "next": "..."}
],
"all_tasks_count": 5,
"raw": "<полный STATUS.md>"
}
],
"errors": [{"project": "books", "reason": "network"}]
}
```
5. **Exit 0 даже при частичных ошибках.** Логи в `~/.cache/projects-mcp/sync.log`.
### 3. `mcp-server`
Stateless Node-процесс, stdio-транспорт MCP. Читает только локальные файлы. Не делает сетевых вызовов.
#### Tool surface
| Tool | Аргументы | Возвращает |
|------|-----------|------------|
| `tasks.aggregate()` | `(filter?: { status?, project? })` | Список активных задач по всем проектам. Только мета (slug, project, status, next-action), не полный текст. |
| `tasks.search(query)` | `query: string` | Поиск по `STATUS.md`-кэшу (substring + per-task title). |
| `tasks.get(project)` | `project: string` | Полный `STATUS.md` одного проекта. |
| `knowledge.search(query, opts?)` | `query: string, opts?: { domain?: string\|"all", limit?: number }` | До 10 заголовков + 1-строчное описание. По умолчанию фильтр зависит от detected domain текущего проекта (см. ниже). |
| `knowledge.get(slug)` | `slug: string` (например `node/windows-yarn-exec`) | Полная страница (frontmatter + тело). |
| `knowledge.suggest_promote()` | `()` | Возвращает кандидатов на промоушен из текущего проекта. См. логику ниже. |
| `meta.status()` | `()` | Возвращает диагностику: когда был последний sync, кэш-stale-возраст, сколько проектов, сколько shared-страниц. |
#### Two-step паттерн
`search` всегда возвращает только индекс (заголовок + 1 строка). `get` возвращает полный текст. Минимизирует токены — модель сама решает что углублять.
#### Detect domain текущего проекта
При запуске MCP получает `cwd` от клиента. Алгоритм:
1. Если в `cwd` есть `package.json` → `node`.
2. Если есть `platformio.ini` / `*.ino` / `CMakeLists.txt` с упоминанием `arm-none-eabi` или embedded SDK → `embedded`.
3. Если есть `next.config.*` / `vite.config.*` / `index.html` без `package.json` (статика) → `web`.
4. Иначе → `unknown`.
Логика фильтрации `knowledge.search`:
- Detected domain — конкретный (`node` / `embedded` / `web`) → показываем `domain == detected || domain == "cross"`.
- Detected domain `unknown` → фильтра нет (видны все домены).
- Опциональный аргумент `opts.domain` пользователя/Claude переопределяет: `"all"` снимает фильтр явно, конкретное имя меняет таргет.
`cross` в frontmatter — это **тег страницы** (универсальное знание). Detected `unknown` — это **режим поиска** (фильтра нет). Не путать.
Логика выносится в отдельный модуль `domain-detector` (тестируется юнит-тестами).
#### `knowledge.suggest_promote()`
Логика:
1. Прочесть все `concepts/*.md` в `<cwd>/.wiki/concepts/`.
2. Для каждого применить эвристику:
- **Включить как кандидата**, если страница: упоминает платформу/тулинг (Windows, yarn, Node, MCP, git, Docker), не упоминает доменные имена из чёрного списка `projects-wiki/CLAUDE.md`.
- **Усилить сигнал**, если в `projects-wiki` есть страница с близким заголовком в другом домене (= знание подтверждено повторно).
3. Вернуть список кандидатов: `{ slug, current_path, suggested_domain, confidence, reason }`.
4. Решение принимает пользователь (Claude в чате предлагает строкой `_Кандидат на shared: ... Промоутить?_`, ждёт `y`/`n`).
Промоушен ≠ автомат. Tool только **возвращает кандидатов**, ничего не пишет.
### 4. Bootstrap
На новой машине (пример Windows + Git Bash; на других ОС адаптировать пути):
```bash
# один раз:
GITEA=https://git.kzntsv.site
OWNER=OpeItcLoc03
git clone $GITEA/$OWNER/projects-meta-mcp ~/.local/projects-meta-mcp
cd ~/.local/projects-meta-mcp && npm install && npm run build
git clone $GITEA/$OWNER/projects-wiki ~/projects/.wiki
mkdir -p ~/.config/projects-mcp
cp ~/.local/projects-meta-mcp/auth.toml.example ~/.config/projects-mcp/auth.toml
# отредактировать auth.toml — заполнить gitea_token
node ~/.local/projects-meta-mcp/dist/sync.js # первый sync
# зарегистрировать MCP в claude code config (см. ниже)
```
**Формат `auth.toml`:**
```toml
gitea_url = "https://git.kzntsv.site"
gitea_user = "OpeItcLoc03"
gitea_token = "..." # personal access token, scope: read:repository
```
**Регистрация в Claude Code** (`~/.claude.json` или per-project) — пути в Windows-формате с прямыми слэшами:
```json
{
"mcpServers": {
"projects-meta": {
"command": "node",
"args": ["C:/Users/vitya/.local/projects-meta-mcp/dist/server.js"]
}
}
}
```
## Поведение в offline
| Ситуация | Что работает | Что не работает |
|----------|--------------|------------------|
| Сеть есть, синк свежий | Всё | — |
| Сеть есть, sync не запускался N часов | Всё (кэш отдаёт старое) | Свежесть других машин |
| Сеть пропала | `tasks.*`, `knowledge.search/get` — всё работает | `knowledge.suggest_promote` работает; `git push` после промоушена откладывается |
| Кэш отсутствует (новая машина без bootstrap) | Только `meta.status()` (отдаёт diagnostic с пустым кэшем) | Всё остальное |
`meta.status()` всегда возвращает возраст кэша, чтобы Claude мог предупредить пользователя «данные старше 24 часов».
## Авторизация
- Gitea API token: scope `read:repository` (и `write:repository` для пуша projects-wiki).
- Хранится в `~/.config/projects-mcp/auth.toml` (gitignore'd).
- MCP-сервер сам не читает токен — только `sync-script`. MCP оперирует уже скачанными локальными файлами.
## Тестирование
- **Unit:** `domain-detector`, парсер `STATUS.md`, эвристика промоушена.
- **Integration:** sync-script против локального Gitea (Docker fixture). Проверка: rate limit, 404 на отсутствующий `STATUS.md`, частичный отказ.
- **MCP smoke test:** запустить сервер, дёрнуть каждый tool через MCP-клиент-fixture.
## Открытые вопросы (не блокеры)
1. **Cron vs git-hook vs руками** — определится при первом использовании. Стартовать с «руками + npm script», добавить cron когда станет лень.
2. **Уведомление о stale-кэше** — порог в часах? Стартовать с 24, корректировать.
3. **Multi-user** — пока однопользовательский (`OpeItcLoc03`). Если кто-то ещё начнёт пользоваться — переделать `users/{owner}/repos` в параметр из `auth.toml`.
4. **`projects-wiki` GC** — раз в полгода аудит, какие страницы не использовались. Будет отдельный tool `meta.audit_shared(months)` в v2.
## Дальнейшие шаги
1. Создать репозиторий `projects-meta-mcp` на Gitea, запушить этот скелет.
2. Создать репозиторий `projects-wiki` на Gitea с минимальной структурой (`CLAUDE.md`, `cross/`, `node/`).
3. Перейти к фазе writing-plans: расписать пошаговый план реализации (sync-script сначала, MCP-сервер вторым, эвристика промоушена третьим).

View File

@@ -0,0 +1,170 @@
# Federated knowledge query — `knowledge.ask_projects` / `knowledge.get_from`
**Status:** design approved 2026-04-30
**Repo:** `projects-meta-mcp`
**Adds:** 2 read-only MCP tools + 1 sync extension + 1 cache field
## Problem
Сейчас `knowledge.search` / `knowledge.get` читают только из shared `projects-wiki` (общая мета-вики). Если знания там нет — у агента тупик. При этом локальные `.wiki/` отдельных проектов могут это знание содержать (например, `node-utils/.wiki/concepts/windows-yarn-exec.md` — частный кейс который никто ещё не промоутил в shared).
Хотим: если в shared не нашлось → опросить **конкретные** другие проекты (юзер указывает имена) на предмет того же знания. Если и там нет — оформить через уже существующий `tasks.create({target_project, body})`.
## Non-goals
- **Broadcast по всем проектам.** Спрашиваем только перечисленные явно — экономим токены и сетку, юзер знает кого спрашивать.
- **Авто-fallback внутри `knowledge.search`.** `search` остаётся узким (только shared); агент сам решает звать ли `ask_projects`.
- **Новый "request-knowledge-prep" механизм.** `tasks.create` уже умеет ставить задачу другому проекту — Оккам.
- **Confirm-gate / dry-run.** Оба новых тула read-only.
## Architecture
### Tools (новые, read-only)
#### `knowledge.ask_projects(query, projects[], limit?, types?)`
Ищет в `.wiki/` указанных репо.
**Input:**
| field | type | required | default |
|---|---|---|---|
| `query` | string | yes | — |
| `projects` | string[] | yes | — |
| `limit` | int (120) | no | `5` (per project) |
| `types` | string[] | no | `["entities","concepts","packages","sources"]` (`raw` исключён по дефолту — тяжёлые dumps; чтобы включить, явно перечисли все 5) |
**Output:**
```json
{
"asked_projects": ["foo", "bar"],
"results": [
{
"project": "foo",
"matches": [
{"slug": "concepts/x", "type": "concepts", "title": "...", "snippet": "..."}
]
},
{"project": "bar", "error": "no wiki — repo has no .wiki/index.md"}
],
"next_action_hint": "knowledge.get_from(project, slug) для полного текста; tasks.create(target_project, body) если знание не оформлено"
}
```
#### `knowledge.get_from(project, slug)`
Полный текст одной страницы из конкретного проекта (live через Gitea API).
**Input:**
| field | type | required |
|---|---|---|
| `project` | string | yes |
| `slug` | string | yes — относительный путь под `.wiki/` без `.md`, включает type-префикс (например `concepts/foo` или `entities/bar`) |
**Output:** raw markdown body, либо `{error}` если не найдено.
### Sync extension
Расширяем `runSync` в [src/lib/sync-runner.ts](../../src/lib/sync-runner.ts):
- В per-repo worker добавляется параллельный `getRawFile(user, repo, '.wiki/index.md', branch)` рядом с уже существующим `.tasks/STATUS.md`.
- Если оба `null` → skip как и раньше. Если есть хоть один — пишем `ProjectStatus`.
### Cache extension
В [src/lib/cache.ts](../../src/lib/cache.ts):
```ts
interface ProjectStatus {
// ...existing fields
wiki_index?: string; // NEW — raw .wiki/index.md, undefined если нет
}
```
Один общий `cache.json`, не плодим параллельный кэш.
### Index parser
Новый `src/lib/wiki-index-parser.ts`:
- Парсит canonical `index.md` (формат из [src/lib/wiki-writer.ts](../../src/lib/wiki-writer.ts) `freshIndexMd()` / `insertIndexEntry()`).
- Возвращает `Array<{slug: string; type: WikiPageType; title: string}>`.
- Структура — секции по типам, под каждой list `- [title](type/slug.md)`.
### Read flow `ask_projects`
1. `readCache(cacheFile)` → найти проекты из `projects[]` параметра по `name`.
2. Для каждого:
- `wiki_index === undefined``{project, error: "no wiki — ..."}`
- Иначе: парсим index, фильтр по `types`, substring case-insensitive по `slug` + `title` против `query`.
- Берём первые `limit` совпадений.
- Для каждого — `backend.getRawFile(user, project, '.wiki/' + type + '/' + slug + '.md', branch)` (concurrency-pool).
- Из body вырезаем snippet (~80 char вокруг матча, как в `knowledge.search`).
3. Если проекта нет в кэше → `{project, error: "unknown project — run sync first"}`.
4. Если live fetch упал → matched entry с `snippet: "(fetch failed)"`, slug+title сохраняем.
### Read flow `get_from`
1. `readCache` → найти project → `default_branch`.
2. `backend.getRawFile(user, project, '.wiki/' + slug + '.md', branch)` — slug включает type-префикс (тот же формат что отдаёт `ask_projects` в `matches[].slug`).
3. `null` → error `page not found: <project>/.wiki/<slug>.md`.
## Matching semantics
Реплицируем `knowledge.search`:
- Substring case-insensitive
- Сначала по `slug` + `title` (на index)
- Потом по `body` (для snippet) при fetched-странице
- `raw` тип исключён по дефолту (тяжёлые dumps); включается явным перечислением — `types: ["concepts","raw"]` или все 5 типов
## Error branches
| Случай | Поведение |
|---|---|
| Index.md отсутствует в репо | `{project, error: "no wiki"}` — тихо, остальные проекты обрабатываются |
| Project не в `cache.projects[]` | `{project, error: "unknown project — run sync first"}` |
| Live fetch страницы упал | matched entry с `snippet: "(fetch failed)"` |
| Парсинг index.md упал | `{project, error: "wiki index malformed: <reason>"}` |
| `backend` не сконфигурирован (auth.toml нет) | tool возвращает error — как сейчас у write-tools |
## Configuration
Новых конфиг-полей нет. Целевые проекты передаются параметром `projects[]`. Sync уже знает все репы юзера через `listUserRepos`.
## Testing
Все новые тесты — vitest, stub Backend. Никаких реальных Gitea-вызовов.
| Файл | Что покрывает |
|---|---|
| `tests/lib/wiki-index-parser.test.ts` | Валидный index.md, пустые секции, поломанный markdown, nested slugs (`concepts/foo/bar`) |
| `tests/lib/sync-runner.test.ts` (расширение) | `wiki_index` подтягивается параллельно с STATUS.md; репо только с `.wiki/` без `.tasks/` попадает в кэш; ни того ни другого → skip |
| `tests/tools/knowledge-ask-projects.test.ts` | Match по slug+title, default types-фильтр (raw исключён), limit per project, error-branches (`no wiki`, `unknown project`, fetch fail, malformed index), partial success (один проект упал, другие отдали) |
| `tests/tools/knowledge-get-from.test.ts` | Happy path, project not found, page not found |
## Files
**New:**
- `src/lib/wiki-index-parser.ts`
- `tests/lib/wiki-index-parser.test.ts`
- `tests/tools/knowledge-ask-projects.test.ts`
- `tests/tools/knowledge-get-from.test.ts`
**Modify:**
- `src/lib/cache.ts``wiki_index?: string` в `ProjectStatus`
- `src/lib/sync-runner.ts` — параллельный `getRawFile` для `.wiki/index.md`
- `src/tools/knowledge.ts` — 2 новых тула, описания включают цепочку с `tasks.create` для случая "знания нет"
- `src/server.ts` — проверить что `cacheFile` + `backend` пробрасываются в `makeKnowledgeTools` (уже частично есть)
- `tests/lib/sync-runner.test.ts` — расширить existing
## Implementation phases
1. **Parser**`wiki-index-parser.ts` + тест.
2. **Cache + sync** — поле `wiki_index`, расширенный `runSync`, тесты.
3. **`ask_projects`** — тул + тест.
4. **`get_from`** — тул + тест.
5. **Wiring**`server.ts` deps + `description` тулов с hint про `tasks.create`.
6. **Smoke**`npm run build`, реальный sync, ручной вызов из новой Claude-сессии.
## Out of scope (для будущих итераций)
- Авто-suggest проектов в response от `knowledge.search` (heuristic / тематический mapping).
- Federated write — пробрасывать ingest сразу в чужой репо (можно уже сейчас через `knowledge.ingest({target_project: "foo", ...})` — есть в MVP-4).
- Кэширование тел страниц (сейчас кэшируем только index — это компромисс размер/свежесть).