From ca669d96e14cd68e1b54446c7990cebcde8a4285 Mon Sep 17 00:00:00 2001 From: vitya Date: Thu, 11 Jun 2026 13:17:12 +0300 Subject: [PATCH] feat(lib): bundle MCP servers from .common/lib into factory/lib/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 7 + bootstrap.ps1 | 47 + lib/interns-mcp/interns_mcp/__init__.py | 1 + lib/interns-mcp/interns_mcp/client.py | 43 + .../interns_mcp/interns/__init__.py | 0 lib/interns-mcp/interns_mcp/interns/base.py | 52 + .../interns_mcp/interns/bulk_text_read.py | 46 + .../interns_mcp/interns/grep_audit.py | 104 + .../interns_mcp/interns/repo_read.py | 164 + .../interns_mcp/interns/transcript_distill.py | 46 + lib/interns-mcp/interns_mcp/registry.py | 38 + lib/interns-mcp/interns_mcp/safety.py | 65 + lib/interns-mcp/interns_mcp/server.py | 114 + lib/interns-mcp/pyproject.toml | 18 + lib/interns-mcp/tests/test_client.py | 21 + lib/interns-mcp/tests/test_grep_audit.py | 237 ++ lib/interns-mcp/tests/test_registry.py | 40 + lib/interns-mcp/tests/test_repo_read.py | 283 ++ lib/interns-mcp/tests/test_safety.py | 54 + lib/projects-meta-mcp/.gitignore | 11 + lib/projects-meta-mcp/CLAUDE.md | 11 + lib/projects-meta-mcp/README.md | 74 + lib/projects-meta-mcp/auth.toml.example | 31 + .../2026-04-29-bootstrap-implementation.md | 3087 +++++++++++++++++ ...-04-30-federated-knowledge-ask-projects.md | 1091 ++++++ .../2026-04-29-projects-meta-mcp-design.md | 269 ++ ...federated-knowledge-ask-projects-design.md | 170 + lib/projects-meta-mcp/package-lock.json | 2685 ++++++++++++++ lib/projects-meta-mcp/package.json | 33 + lib/projects-meta-mcp/src/lib/backend.ts | 63 + lib/projects-meta-mcp/src/lib/cache.ts | 86 + lib/projects-meta-mcp/src/lib/claim.ts | 227 ++ lib/projects-meta-mcp/src/lib/config.ts | 103 + .../src/lib/decision-trail-writer.ts | 72 + .../src/lib/domain-detector.ts | 146 + lib/projects-meta-mcp/src/lib/git.ts | 30 + lib/projects-meta-mcp/src/lib/gitea.ts | 119 + lib/projects-meta-mcp/src/lib/meta-cli.ts | 84 + .../src/lib/meta-host-resolver.ts | 77 + lib/projects-meta-mcp/src/lib/meta-sync.ts | 241 ++ lib/projects-meta-mcp/src/lib/policy.ts | 122 + lib/projects-meta-mcp/src/lib/promotion.ts | 107 + .../src/lib/resolve-target.ts | 114 + .../src/lib/status-md-writer.ts | 345 ++ lib/projects-meta-mcp/src/lib/status-md.ts | 193 ++ lib/projects-meta-mcp/src/lib/sync-runner.ts | 200 ++ .../src/lib/wiki-index-parser.ts | 45 + lib/projects-meta-mcp/src/lib/wiki-index.ts | 67 + lib/projects-meta-mcp/src/lib/wiki-writer.ts | 191 + lib/projects-meta-mcp/src/server.ts | 115 + lib/projects-meta-mcp/src/sync.ts | 95 + lib/projects-meta-mcp/src/tasks-cli.ts | 263 ++ lib/projects-meta-mcp/src/tools/knowledge.ts | 691 ++++ lib/projects-meta-mcp/src/tools/meta.ts | 65 + lib/projects-meta-mcp/src/tools/ops.ts | 115 + lib/projects-meta-mcp/src/tools/tasks.ts | 1235 +++++++ lib/projects-meta-mcp/src/tools/types.ts | 21 + lib/projects-meta-mcp/src/tools/workers.ts | 73 + lib/projects-meta-mcp/tests/lib/cache.test.ts | 128 + lib/projects-meta-mcp/tests/lib/claim.test.ts | 418 +++ .../tests/lib/config.test.ts | 200 ++ .../tests/lib/decision-trail-writer.test.ts | 71 + .../tests/lib/domain-detector.test.ts | 187 + lib/projects-meta-mcp/tests/lib/git.test.ts | 37 + lib/projects-meta-mcp/tests/lib/gitea.test.ts | 214 ++ .../tests/lib/meta-cli.test.ts | 127 + .../tests/lib/meta-host-resolver.test.ts | 70 + .../tests/lib/meta-sync.test.ts | 233 ++ .../tests/lib/policy.test.ts | 119 + .../tests/lib/promotion.test.ts | 93 + .../tests/lib/resolve-target.test.ts | 213 ++ .../tests/lib/status-md-writer.test.ts | 383 ++ .../tests/lib/status-md.test.ts | 170 + .../tests/lib/sync-runner.test.ts | 637 ++++ .../tests/lib/wiki-index-parser.test.ts | 80 + .../tests/lib/wiki-index.test.ts | 70 + .../tests/lib/wiki-writer.test.ts | 132 + .../tools/knowledge-ask-projects.test.ts | 475 +++ .../tests/tools/knowledge-get-from.test.ts | 217 ++ .../tests/tools/knowledge.test.ts | 455 +++ .../tests/tools/meta.test.ts | 153 + .../tools/tasks-append-decision-trail.test.ts | 173 + .../tests/tools/tasks-cli.test.ts | 373 ++ .../tests/tools/tasks-get-status.test.ts | 102 + .../tests/tools/tasks-list-blocked.test.ts | 197 ++ .../tests/tools/tasks-park-question.test.ts | 174 + .../tests/tools/tasks.test.ts | 1473 ++++++++ .../tests/tools/workers.test.ts | 49 + lib/projects-meta-mcp/tsconfig.json | 19 + lib/projects-meta-mcp/vitest.config.ts | 8 + lib/wiki-graph/.gitignore | 10 + lib/wiki-graph/package-lock.json | 2578 ++++++++++++++ lib/wiki-graph/package.json | 27 + lib/wiki-graph/scripts/smoke.mjs | 82 + lib/wiki-graph/src/cache.ts | 60 + lib/wiki-graph/src/corpus.ts | 59 + lib/wiki-graph/src/graph.ts | 221 ++ lib/wiki-graph/src/index.ts | 25 + lib/wiki-graph/src/parser.ts | 30 + lib/wiki-graph/src/resolver.ts | 49 + lib/wiki-graph/src/server.ts | 55 + lib/wiki-graph/src/tools.ts | 166 + lib/wiki-graph/tests/cache.test.ts | 59 + .../tests/fixtures/wiki/concepts/a.md | 3 + .../tests/fixtures/wiki/concepts/b.md | 3 + .../tests/fixtures/wiki/concepts/c.md | 3 + .../tests/fixtures/wiki/concepts/d.md | 3 + .../tests/fixtures/wiki/concepts/e.md | 3 + lib/wiki-graph/tests/fixtures/wiki/raw/a.md | 4 + .../tests/fixtures/wiki/sources/ghost.md | 4 + lib/wiki-graph/tests/graph.test.ts | 102 + lib/wiki-graph/tests/parser.test.ts | 34 + lib/wiki-graph/tests/resolver.test.ts | 46 + lib/wiki-graph/tests/tools.test.ts | 81 + lib/wiki-graph/tsconfig.json | 19 + lib/wiki-graph/vitest.config.ts | 8 + 116 files changed, 25331 insertions(+) create mode 100644 lib/interns-mcp/interns_mcp/__init__.py create mode 100644 lib/interns-mcp/interns_mcp/client.py create mode 100644 lib/interns-mcp/interns_mcp/interns/__init__.py create mode 100644 lib/interns-mcp/interns_mcp/interns/base.py create mode 100644 lib/interns-mcp/interns_mcp/interns/bulk_text_read.py create mode 100644 lib/interns-mcp/interns_mcp/interns/grep_audit.py create mode 100644 lib/interns-mcp/interns_mcp/interns/repo_read.py create mode 100644 lib/interns-mcp/interns_mcp/interns/transcript_distill.py create mode 100644 lib/interns-mcp/interns_mcp/registry.py create mode 100644 lib/interns-mcp/interns_mcp/safety.py create mode 100644 lib/interns-mcp/interns_mcp/server.py create mode 100644 lib/interns-mcp/pyproject.toml create mode 100644 lib/interns-mcp/tests/test_client.py create mode 100644 lib/interns-mcp/tests/test_grep_audit.py create mode 100644 lib/interns-mcp/tests/test_registry.py create mode 100644 lib/interns-mcp/tests/test_repo_read.py create mode 100644 lib/interns-mcp/tests/test_safety.py create mode 100644 lib/projects-meta-mcp/.gitignore create mode 100644 lib/projects-meta-mcp/CLAUDE.md create mode 100644 lib/projects-meta-mcp/README.md create mode 100644 lib/projects-meta-mcp/auth.toml.example create mode 100644 lib/projects-meta-mcp/docs/superpowers/plans/2026-04-29-bootstrap-implementation.md create mode 100644 lib/projects-meta-mcp/docs/superpowers/plans/2026-04-30-federated-knowledge-ask-projects.md create mode 100644 lib/projects-meta-mcp/docs/superpowers/specs/2026-04-29-projects-meta-mcp-design.md create mode 100644 lib/projects-meta-mcp/docs/superpowers/specs/2026-04-30-federated-knowledge-ask-projects-design.md create mode 100644 lib/projects-meta-mcp/package-lock.json create mode 100644 lib/projects-meta-mcp/package.json create mode 100644 lib/projects-meta-mcp/src/lib/backend.ts create mode 100644 lib/projects-meta-mcp/src/lib/cache.ts create mode 100644 lib/projects-meta-mcp/src/lib/claim.ts create mode 100644 lib/projects-meta-mcp/src/lib/config.ts create mode 100644 lib/projects-meta-mcp/src/lib/decision-trail-writer.ts create mode 100644 lib/projects-meta-mcp/src/lib/domain-detector.ts create mode 100644 lib/projects-meta-mcp/src/lib/git.ts create mode 100644 lib/projects-meta-mcp/src/lib/gitea.ts create mode 100644 lib/projects-meta-mcp/src/lib/meta-cli.ts create mode 100644 lib/projects-meta-mcp/src/lib/meta-host-resolver.ts create mode 100644 lib/projects-meta-mcp/src/lib/meta-sync.ts create mode 100644 lib/projects-meta-mcp/src/lib/policy.ts create mode 100644 lib/projects-meta-mcp/src/lib/promotion.ts create mode 100644 lib/projects-meta-mcp/src/lib/resolve-target.ts create mode 100644 lib/projects-meta-mcp/src/lib/status-md-writer.ts create mode 100644 lib/projects-meta-mcp/src/lib/status-md.ts create mode 100644 lib/projects-meta-mcp/src/lib/sync-runner.ts create mode 100644 lib/projects-meta-mcp/src/lib/wiki-index-parser.ts create mode 100644 lib/projects-meta-mcp/src/lib/wiki-index.ts create mode 100644 lib/projects-meta-mcp/src/lib/wiki-writer.ts create mode 100644 lib/projects-meta-mcp/src/server.ts create mode 100644 lib/projects-meta-mcp/src/sync.ts create mode 100644 lib/projects-meta-mcp/src/tasks-cli.ts create mode 100644 lib/projects-meta-mcp/src/tools/knowledge.ts create mode 100644 lib/projects-meta-mcp/src/tools/meta.ts create mode 100644 lib/projects-meta-mcp/src/tools/ops.ts create mode 100644 lib/projects-meta-mcp/src/tools/tasks.ts create mode 100644 lib/projects-meta-mcp/src/tools/types.ts create mode 100644 lib/projects-meta-mcp/src/tools/workers.ts create mode 100644 lib/projects-meta-mcp/tests/lib/cache.test.ts create mode 100644 lib/projects-meta-mcp/tests/lib/claim.test.ts create mode 100644 lib/projects-meta-mcp/tests/lib/config.test.ts create mode 100644 lib/projects-meta-mcp/tests/lib/decision-trail-writer.test.ts create mode 100644 lib/projects-meta-mcp/tests/lib/domain-detector.test.ts create mode 100644 lib/projects-meta-mcp/tests/lib/git.test.ts create mode 100644 lib/projects-meta-mcp/tests/lib/gitea.test.ts create mode 100644 lib/projects-meta-mcp/tests/lib/meta-cli.test.ts create mode 100644 lib/projects-meta-mcp/tests/lib/meta-host-resolver.test.ts create mode 100644 lib/projects-meta-mcp/tests/lib/meta-sync.test.ts create mode 100644 lib/projects-meta-mcp/tests/lib/policy.test.ts create mode 100644 lib/projects-meta-mcp/tests/lib/promotion.test.ts create mode 100644 lib/projects-meta-mcp/tests/lib/resolve-target.test.ts create mode 100644 lib/projects-meta-mcp/tests/lib/status-md-writer.test.ts create mode 100644 lib/projects-meta-mcp/tests/lib/status-md.test.ts create mode 100644 lib/projects-meta-mcp/tests/lib/sync-runner.test.ts create mode 100644 lib/projects-meta-mcp/tests/lib/wiki-index-parser.test.ts create mode 100644 lib/projects-meta-mcp/tests/lib/wiki-index.test.ts create mode 100644 lib/projects-meta-mcp/tests/lib/wiki-writer.test.ts create mode 100644 lib/projects-meta-mcp/tests/tools/knowledge-ask-projects.test.ts create mode 100644 lib/projects-meta-mcp/tests/tools/knowledge-get-from.test.ts create mode 100644 lib/projects-meta-mcp/tests/tools/knowledge.test.ts create mode 100644 lib/projects-meta-mcp/tests/tools/meta.test.ts create mode 100644 lib/projects-meta-mcp/tests/tools/tasks-append-decision-trail.test.ts create mode 100644 lib/projects-meta-mcp/tests/tools/tasks-cli.test.ts create mode 100644 lib/projects-meta-mcp/tests/tools/tasks-get-status.test.ts create mode 100644 lib/projects-meta-mcp/tests/tools/tasks-list-blocked.test.ts create mode 100644 lib/projects-meta-mcp/tests/tools/tasks-park-question.test.ts create mode 100644 lib/projects-meta-mcp/tests/tools/tasks.test.ts create mode 100644 lib/projects-meta-mcp/tests/tools/workers.test.ts create mode 100644 lib/projects-meta-mcp/tsconfig.json create mode 100644 lib/projects-meta-mcp/vitest.config.ts create mode 100644 lib/wiki-graph/.gitignore create mode 100644 lib/wiki-graph/package-lock.json create mode 100644 lib/wiki-graph/package.json create mode 100644 lib/wiki-graph/scripts/smoke.mjs create mode 100644 lib/wiki-graph/src/cache.ts create mode 100644 lib/wiki-graph/src/corpus.ts create mode 100644 lib/wiki-graph/src/graph.ts create mode 100644 lib/wiki-graph/src/index.ts create mode 100644 lib/wiki-graph/src/parser.ts create mode 100644 lib/wiki-graph/src/resolver.ts create mode 100644 lib/wiki-graph/src/server.ts create mode 100644 lib/wiki-graph/src/tools.ts create mode 100644 lib/wiki-graph/tests/cache.test.ts create mode 100644 lib/wiki-graph/tests/fixtures/wiki/concepts/a.md create mode 100644 lib/wiki-graph/tests/fixtures/wiki/concepts/b.md create mode 100644 lib/wiki-graph/tests/fixtures/wiki/concepts/c.md create mode 100644 lib/wiki-graph/tests/fixtures/wiki/concepts/d.md create mode 100644 lib/wiki-graph/tests/fixtures/wiki/concepts/e.md create mode 100644 lib/wiki-graph/tests/fixtures/wiki/raw/a.md create mode 100644 lib/wiki-graph/tests/fixtures/wiki/sources/ghost.md create mode 100644 lib/wiki-graph/tests/graph.test.ts create mode 100644 lib/wiki-graph/tests/parser.test.ts create mode 100644 lib/wiki-graph/tests/resolver.test.ts create mode 100644 lib/wiki-graph/tests/tools.test.ts create mode 100644 lib/wiki-graph/tsconfig.json create mode 100644 lib/wiki-graph/vitest.config.ts diff --git a/.gitignore b/.gitignore index 067cb3b..71f97ae 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,13 @@ dist/*-linux-* dist/*-darwin-* dist/*-windows-*.exe.bak +# MCP server build artefacts (lib/ contains sources; bootstrap builds on install) +lib/*/node_modules/ +lib/*/dist/ +lib/*/.venv/ +lib/**/__pycache__/ +lib/**/*.pyc + # Local dev state *.log .vscode/ diff --git a/bootstrap.ps1 b/bootstrap.ps1 index 2bf89a4..516d109 100644 --- a/bootstrap.ps1 +++ b/bootstrap.ps1 @@ -174,6 +174,53 @@ $lines = @( Write-Ok "Wrote $HOME_TOML" +# Build MCP servers from lib/ +Write-Step "Building MCP servers" + +$libPath = "$factoryLocal\lib" + +if (Test-Path $libPath) { + # TypeScript servers (npm install + npm run build) + foreach ($server in @("projects-meta-mcp", "wiki-graph")) { + $serverPath = "$libPath\$server" + if (Test-Path $serverPath) { + Write-Host " Building $server..." + Push-Location $serverPath + try { + npm install --silent 2>&1 | Out-Null + npm run build 2>&1 | Out-Null + if ($LASTEXITCODE -ne 0) { + Write-Gap "$server: build failed (npm run build exited $LASTEXITCODE)" + } else { + Write-Ok "$server built" + } + } finally { + Pop-Location + } + } + } + + # Python server (interns-mcp) — venv + pip install + $internsMcpPath = "$libPath\interns-mcp" + if (Test-Path $internsMcpPath) { + Write-Host " Setting up interns-mcp..." + Push-Location $internsMcpPath + try { + python -m venv .venv 2>&1 | Out-Null + & ".venv\Scripts\pip.exe" install -e . --quiet + if ($LASTEXITCODE -ne 0) { + Write-Gap "interns-mcp: pip install failed" + } else { + Write-Ok "interns-mcp installed" + } + } finally { + Pop-Location + } + } +} else { + Write-Skip "lib/ not found — skipping MCP server build" +} + # Print next steps Write-Host "`n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -ForegroundColor Green Write-Host " L0b COMPLETE" -ForegroundColor Green diff --git a/lib/interns-mcp/interns_mcp/__init__.py b/lib/interns-mcp/interns_mcp/__init__.py new file mode 100644 index 0000000..48e5a98 --- /dev/null +++ b/lib/interns-mcp/interns_mcp/__init__.py @@ -0,0 +1 @@ +"""Interns MCP — cheap LLM interns for Claude Code.""" \ No newline at end of file diff --git a/lib/interns-mcp/interns_mcp/client.py b/lib/interns-mcp/interns_mcp/client.py new file mode 100644 index 0000000..af0fbd0 --- /dev/null +++ b/lib/interns-mcp/interns_mcp/client.py @@ -0,0 +1,43 @@ +"""OpenAI-compatible HTTP client, persistent per-endpoint.""" + +from __future__ import annotations + +from typing import Any + +from openai import OpenAI + +# Bound every LLM call. Without this the SDK default (600s) lets a stalled +# endpoint hang the worker thread that ran the tool; enough such threads exhaust +# FastMCP's pool and wedge the whole stdio server. 90s is well above a healthy +# deepseek-v4-flash response yet short enough to fail fast and free the thread. +DEFAULT_REQUEST_TIMEOUT = 90.0 + + +def _resolve_timeout(endpoint_cfg: dict[str, Any]) -> float: + """Per-endpoint request timeout in seconds; falls back to the bounded default.""" + return float(endpoint_cfg.get("request_timeout", DEFAULT_REQUEST_TIMEOUT)) + + +def make_client(endpoint_cfg: dict[str, Any], api_key: str) -> OpenAI: + """Create a persistent OpenAI client for an endpoint.""" + kwargs: dict[str, Any] = { + "base_url": endpoint_cfg["base_url"], + "api_key": api_key, + "timeout": _resolve_timeout(endpoint_cfg), + } + defaults = endpoint_cfg.get("request_defaults", {}) + if "extra_body" in defaults: + kwargs["default_query"] = {} + return OpenAI(**kwargs) + + +class ClientPool: + """Manages persistent HTTP clients per endpoint.""" + + def __init__(self) -> None: + self._clients: dict[str, OpenAI] = {} + + def get(self, name: str, endpoint_cfg: dict[str, Any], api_key: str) -> OpenAI: + if name not in self._clients: + self._clients[name] = make_client(endpoint_cfg, api_key) + return self._clients[name] \ No newline at end of file diff --git a/lib/interns-mcp/interns_mcp/interns/__init__.py b/lib/interns-mcp/interns_mcp/interns/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lib/interns-mcp/interns_mcp/interns/base.py b/lib/interns-mcp/interns_mcp/interns/base.py new file mode 100644 index 0000000..fefbc68 --- /dev/null +++ b/lib/interns-mcp/interns_mcp/interns/base.py @@ -0,0 +1,52 @@ +"""Base intern protocol and response types.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class InternResponse: + text: str + usage: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class BlockedByPolicy: + path: str + pattern: str + reason: str + + +@dataclass +class BudgetExceededError: + tokens: int + limit: int + top_files: list[str] = field(default_factory=list) + hint: str = "use compress=True or narrow paths" + + +class Intern: + id: str = "" + description: str = "" + endpoint: str | None = None + model: str | None = None + max_tokens: int = 4096 + temperature: float = 0.2 + system_prompt: str = "" + context_budget: int = 120000 + + def __init__(self, config: dict[str, Any] | None = None, client: Any = None, safety: Any = None): + self.client = client + self.safety = safety + if config: + self._apply_config(config) + + def _apply_config(self, config: dict[str, Any]) -> None: + for k, v in config.items(): + if hasattr(self, k): + setattr(self, k, v) + + def run(self, paths: list[str], question: str, **kwargs: Any) -> InternResponse | BlockedByPolicy | BudgetExceededError: + raise NotImplementedError \ No newline at end of file diff --git a/lib/interns-mcp/interns_mcp/interns/bulk_text_read.py b/lib/interns-mcp/interns_mcp/interns/bulk_text_read.py new file mode 100644 index 0000000..7bc5464 --- /dev/null +++ b/lib/interns-mcp/interns_mcp/interns/bulk_text_read.py @@ -0,0 +1,46 @@ +"""Bulk text read intern — read N files and answer a focused question.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from .base import BlockedByPolicy, Intern, InternResponse + + +class BulkTextRead(Intern): + id = "bulk_text_read" + description = "Read N files and answer a focused question. Returns concise summary." + + def run(self, paths: list[str], question: str, **kwargs: Any) -> InternResponse | BlockedByPolicy: + contents: list[str] = [] + for p in paths: + text = Path(p).read_text(encoding="utf-8", errors="replace") + contents.append(f"--- {p} ---\n{text}") + + combined = "\n\n".join(contents) + prompt = f"Files:\n{combined}\n\nQuestion: {question}" + + if self.client is None: + return InternResponse(text="No client configured", usage={}) + + extra_body = kwargs.get("extra_body", {}) + resp = self.client.chat.completions.create( + model=self.model or "deepseek-v4-flash", + messages=[ + {"role": "system", "content": self.system_prompt}, + {"role": "user", "content": prompt}, + ], + max_tokens=kwargs.get("max_tokens", self.max_tokens), + temperature=self.temperature, + extra_body=extra_body if extra_body else None, + ) + + choice = resp.choices[0] + return InternResponse( + text=choice.message.content or "", + usage={ + "tokens_in": resp.usage.prompt_tokens if resp.usage else 0, + "tokens_out": resp.usage.completion_tokens if resp.usage else 0, + }, + ) \ No newline at end of file diff --git a/lib/interns-mcp/interns_mcp/interns/grep_audit.py b/lib/interns-mcp/interns_mcp/interns/grep_audit.py new file mode 100644 index 0000000..14c2486 --- /dev/null +++ b/lib/interns-mcp/interns_mcp/interns/grep_audit.py @@ -0,0 +1,104 @@ +"""Grep audit intern — deterministic matrix of N paths x M patterns. No LLM call.""" + +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import Any, Callable, Literal + +from .base import Intern, InternResponse + + +class GrepAudit(Intern): + id = "grep_audit" + description = ( + "Deterministic grep matrix over N paths x M patterns (substring or regex). " + "No LLM call, no endpoint cost. Returns markdown table (default) or JSON." + ) + + def run( + self, + paths: list[str], + patterns: list[str | dict[str, Any]], + output: Literal["table", "json"] = "table", + case_sensitive: bool = True, + **_kwargs: Any, + ) -> InternResponse: + compiled = _compile_patterns(patterns, case_sensitive) + + rows: list[dict[str, Any]] = [] + matches_total = 0 + for p in paths: + try: + text = Path(p).read_text(encoding="utf-8", errors="replace") + except (FileNotFoundError, PermissionError, IsADirectoryError, OSError) as exc: + rows.append( + { + "path": p, + "matches": {c["name"]: None for c in compiled}, + "error": str(exc), + } + ) + continue + row_matches: dict[str, bool] = {} + for c in compiled: + hit = bool(c["matcher"](text)) + row_matches[c["name"]] = hit + if hit: + matches_total += 1 + rows.append({"path": p, "matches": row_matches}) + + if output == "json": + rendered = json.dumps({"rows": rows}, ensure_ascii=False, indent=2) + else: + rendered = _render_table(rows, [c["name"] for c in compiled]) + + return InternResponse( + text=rendered, + usage={ + "files_scanned": len(paths), + "patterns_evaluated": len(compiled), + "matches_total": matches_total, + }, + ) + + +def _compile_patterns( + patterns: list[str | dict[str, Any]], case_sensitive: bool +) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + for p in patterns: + if isinstance(p, str): + out.append({"name": p, "matcher": _substring_matcher(p, case_sensitive)}) + continue + name = p.get("name", p["pattern"]) + if p.get("regex"): + flags = 0 if case_sensitive else re.IGNORECASE + compiled = re.compile(p["pattern"], flags) + out.append({"name": name, "matcher": compiled.search}) + else: + out.append( + {"name": name, "matcher": _substring_matcher(p["pattern"], case_sensitive)} + ) + return out + + +def _substring_matcher(needle: str, case_sensitive: bool) -> Callable[[str], bool]: + if case_sensitive: + return lambda text: needle in text + lowered = needle.lower() + return lambda text: lowered in text.lower() + + +def _render_table(rows: list[dict[str, Any]], names: list[str]) -> str: + header = "| Path | " + " | ".join(names) + " |" + sep = "|" + "---|" * (len(names) + 1) + lines = [header, sep] + for row in rows: + cells: list[str] = [] + for n in names: + v = row["matches"].get(n) + cells.append("⚠️" if v is None else ("✅" if v else "❌")) + lines.append(f"| `{row['path']}` | " + " | ".join(cells) + " |") + return "\n".join(lines) diff --git a/lib/interns-mcp/interns_mcp/interns/repo_read.py b/lib/interns-mcp/interns_mcp/interns/repo_read.py new file mode 100644 index 0000000..777adab --- /dev/null +++ b/lib/interns-mcp/interns_mcp/interns/repo_read.py @@ -0,0 +1,164 @@ +"""Repo read intern — pack repo with repomix, answer question with LLM.""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import tempfile +from pathlib import Path +from typing import Any + +from .base import BudgetExceededError, Intern, InternResponse +from ..safety import always_ask_globs + + +# Conservative token estimate: chars/4 overestimates for repomix XML (tag overhead), +# so BudgetExceededError fires early rather than late. Real enforcement is on the +# endpoint side — this is a client-side safety net, not a precision counter. +CHARS_PER_TOKEN = 4 + + +def _estimate_tokens(text: str) -> int: + return len(text) // CHARS_PER_TOKEN + + +def _top_files_by_size(packed: str, n: int = 5) -> list[str]: + """Return the n largest files in repomix XML output, by block content size. + + Parses each ``...`` block and measures the byte length + of its content, so BudgetExceededError.top_files points the user at the real + overflow culprits — not the first files in document order. + """ + sizes: list[tuple[int, str]] = [] + pos = 0 + while True: + open_idx = packed.find('', open_idx) + if close_idx == -1: + break + path_start = packed.find('path="', open_idx) + 6 + path_end = packed.find('"', path_start) + path = packed[path_start:path_end] + # Content sits between the opening tag's '>' and the closing ''. + content_start = packed.find('>', open_idx) + 1 + content_size = close_idx - content_start + sizes.append((content_size, path)) + pos = close_idx + len('') + + sizes.sort(key=lambda x: x[0], reverse=True) + return [path for _, path in sizes[:n]] + + +class RepoRead(Intern): + id = "repo_read" + description = "Pack directory/repo with repomix and answer a question. Cheap repo comprehension." + + def run( + self, + paths: list[str], + question: str, + compress: bool = False, + **kwargs: Any, + ) -> InternResponse | BudgetExceededError: + if not paths: + return InternResponse(text="No paths provided", usage={}) + + # Build repomix args: pass all paths, not just first. + ignore_globs = always_ask_globs() + + # Resolve npx via PATH. On Windows, subprocess.run(shell=False) does not + # expand PATHEXT, so passing bare "npx" fails to find npx.cmd; shutil.which + # finds the .cmd (or .exe / shell wrapper on POSIX) and returns a full path + # that CreateProcess / execve can dispatch directly. + npx = shutil.which("npx") or shutil.which("npx.cmd") + if npx is None: + return InternResponse(text="npx not found - install Node.js", usage={}) + + # Use mkstemp to avoid Windows file-sharing issues with NamedTemporaryFile. + # os.close(fd) releases the handle before repomix writes to the path. + fd, tmp_path = tempfile.mkstemp(suffix=".xml") + os.close(fd) + err_fd, err_path = tempfile.mkstemp(suffix=".stderr") + os.close(err_fd) + try: + args = [ + npx, "-y", "repomix@latest", + *paths, + "--output", tmp_path, + "--style", "xml", + ] + if compress: + args.append("--compress") + for glob in ignore_globs: + args += ["--ignore", glob] + + try: + # Redirect repomix's stdio to FILES/DEVNULL, never PIPES. capture_output + # spawns reader threads that communicate() joins; if any process in the + # child tree (an orphaned `node` grandchild, or — under concurrent launches + # on Windows — a sibling subprocess that inherited the pipe handle) keeps a + # pipe write-end open, that join blocks FOREVER, past the timeout, wedging + # the worker thread. repomix's real output already goes to --output, so its + # stdout is unused; stderr is captured to a file for error reporting only. + with open(err_path, "w", encoding="utf-8", errors="replace") as err_f: + subprocess.run( + args, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=err_f, + timeout=120, + check=True, + ) + packed_text = Path(tmp_path).read_text() + except subprocess.TimeoutExpired: + return InternResponse(text="Repomix timed out (120s)", usage={}) + except subprocess.CalledProcessError: + err = Path(err_path).read_text(encoding="utf-8", errors="replace") + return InternResponse(text=f"Repomix failed: {err[:500]}", usage={}) + except FileNotFoundError: + return InternResponse(text="npx not found - install Node.js", usage={}) + finally: + # Always clean up the temp files, even on error. + for _p in (tmp_path, err_path): + try: + os.unlink(_p) + except OSError: + pass + + # Budget check before sending to LLM. + tokens = _estimate_tokens(packed_text) + if tokens > self.context_budget: + return BudgetExceededError( + tokens=tokens, + limit=self.context_budget, + top_files=_top_files_by_size(packed_text), + ) + + prompt = f"# Packed repo\n\n{packed_text}\n\n# Question\n\n{question}" + + if self.client is None: + return InternResponse(text="No client configured", usage={}) + + extra_body = kwargs.get("extra_body", {}) + resp = self.client.chat.completions.create( + model=self.model or "deepseek-v4-flash", + messages=[ + {"role": "system", "content": self.system_prompt}, + {"role": "user", "content": prompt}, + ], + max_tokens=kwargs.get("max_tokens", self.max_tokens), + temperature=self.temperature, + extra_body=extra_body if extra_body else None, + ) + + choice = resp.choices[0] + return InternResponse( + text=choice.message.content or "", + usage={ + "tokens_in": resp.usage.prompt_tokens if resp.usage else 0, + "tokens_out": resp.usage.completion_tokens if resp.usage else 0, + }, + ) \ No newline at end of file diff --git a/lib/interns-mcp/interns_mcp/interns/transcript_distill.py b/lib/interns-mcp/interns_mcp/interns/transcript_distill.py new file mode 100644 index 0000000..0aa0a94 --- /dev/null +++ b/lib/interns-mcp/interns_mcp/interns/transcript_distill.py @@ -0,0 +1,46 @@ +"""Transcript distill intern — compress session transcript into structured action-list.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from .base import BlockedByPolicy, Intern, InternResponse + + +class TranscriptDistill(Intern): + id = "transcript_distill" + description = "Compress a session transcript/log into a structured action-list." + + def run(self, paths: list[str], question: str = "Extract action items, decisions, and unresolved questions.", **kwargs: Any) -> InternResponse | BlockedByPolicy: + contents: list[str] = [] + for p in paths: + text = Path(p).read_text(encoding="utf-8", errors="replace") + contents.append(f"--- {p} ---\n{text}") + + combined = "\n\n".join(contents) + prompt = f"Transcript:\n{combined}\n\nTask: {question}" + + if self.client is None: + return InternResponse(text="No client configured", usage={}) + + extra_body = kwargs.get("extra_body", {}) + resp = self.client.chat.completions.create( + model=self.model or "deepseek-v4-flash", + messages=[ + {"role": "system", "content": self.system_prompt}, + {"role": "user", "content": prompt}, + ], + max_tokens=kwargs.get("max_tokens", self.max_tokens), + temperature=self.temperature, + extra_body=extra_body if extra_body else None, + ) + + choice = resp.choices[0] + return InternResponse( + text=choice.message.content or "", + usage={ + "tokens_in": resp.usage.prompt_tokens if resp.usage else 0, + "tokens_out": resp.usage.completion_tokens if resp.usage else 0, + }, + ) \ No newline at end of file diff --git a/lib/interns-mcp/interns_mcp/registry.py b/lib/interns-mcp/interns_mcp/registry.py new file mode 100644 index 0000000..51ef9b2 --- /dev/null +++ b/lib/interns-mcp/interns_mcp/registry.py @@ -0,0 +1,38 @@ +"""Load intern catalog from config.yaml.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import yaml + +from .interns.base import Intern +from .interns.bulk_text_read import BulkTextRead +from .interns.grep_audit import GrepAudit +from .interns.repo_read import RepoRead +from .interns.transcript_distill import TranscriptDistill + +# Explicit registry — MVP choice (see design spec open questions). +INTERN_CLASSES: dict[str, type[Intern]] = { + "bulk_text_read": BulkTextRead, + "grep_audit": GrepAudit, + "repo_read": RepoRead, + "transcript_distill": TranscriptDistill, +} + + +def load_config(config_path: Path) -> dict[str, Any]: + with open(config_path, encoding="utf-8") as f: + return yaml.safe_load(f) + + +def load_interns(config: dict[str, Any], client: Any = None, safety: Any = None) -> list[Intern]: + """Instantiate interns from config, wiring shared client and safety.""" + interns = [] + for intern_id, intern_cfg in config.get("interns", {}).items(): + cls = INTERN_CLASSES.get(intern_id) + if cls is None: + continue + interns.append(cls(config=intern_cfg, client=client, safety=safety)) + return interns \ No newline at end of file diff --git a/lib/interns-mcp/interns_mcp/safety.py b/lib/interns-mcp/interns_mcp/safety.py new file mode 100644 index 0000000..1903204 --- /dev/null +++ b/lib/interns-mcp/interns_mcp/safety.py @@ -0,0 +1,65 @@ +"""Always-ask path matcher — safety enforcement for intern calls.""" + +from __future__ import annotations + +from pathlib import PurePath + +# Patterns that always require explicit user approval before sending to an intern. +ALWAYS_ASK_PATTERNS: list[str] = [ + "**/.env", + "**/.env.*", + "**/secrets/**", + "**/projects-secrets/**", + "**/credentials*", + "**/*.key", + "**/*.pem", + "**/.ssh/**", + "**/.aws/credentials", + "**/.aws/config", + "**/.netrc", + "**/.npmrc", + "**/.pypirc", +] + +ALWAYS_ASK_REASON = ( + "Path matched always-ask pattern. " + "Sending this file to an external endpoint requires explicit approval." +) + + +def check_paths(paths: list[str], extra_patterns: list[str] | None = None) -> list[dict]: + """Check each path against always-ask patterns. + + Returns list of blocked entries: [{"path": ..., "pattern": ..., "reason": ...}]. + Empty list = all paths clear. + """ + patterns = list(ALWAYS_ASK_PATTERNS) + if extra_patterns: + patterns.extend(extra_patterns) + + blocked = [] + for p in paths: + pp = PurePath(p) + for pattern in patterns: + if pp.match(pattern): + blocked.append({"path": p, "pattern": pattern, "reason": ALWAYS_ASK_REASON}) + break + return blocked + + +def always_ask_globs() -> list[str]: + """Return glob patterns for always-ask paths, suitable for --ignore flags. + + Used by tool-wrapping interns (e.g. repo_read) to inject --ignore flags + into subprocess calls, preventing repomix from packaging sensitive files + even if they exist inside the target directory. + """ + # Convert PurePath-style patterns to glob patterns repomix understands. + # Strip leading **/ since repomix --ignore uses gitignore-style patterns. + globs = [] + for p in ALWAYS_ASK_PATTERNS: + if p.startswith("**/"): + globs.append(p[3:]) + else: + globs.append(p) + return globs \ No newline at end of file diff --git a/lib/interns-mcp/interns_mcp/server.py b/lib/interns-mcp/interns_mcp/server.py new file mode 100644 index 0000000..2970807 --- /dev/null +++ b/lib/interns-mcp/interns_mcp/server.py @@ -0,0 +1,114 @@ +"""FastMCP stdio server — MCP entry point for interns.""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any + +from dotenv import load_dotenv +from fastmcp import FastMCP + +from .client import ClientPool +from .registry import load_config, load_interns +from .safety import check_paths + +# Resolve config path relative to .common root; secrets live outside the git tree. +_COMMON_ROOT = Path(__file__).resolve().parents[3] # lib/interns-mcp/interns_mcp/ → .common/ +CONFIG_PATH = _COMMON_ROOT / "config" / "interns" / "config.yaml" +SECRETS_PATH = Path( + os.environ.get("INTERNS_SECRETS_PATH") + or (Path.home() / ".config" / "projects-secrets" / "interns.env") +) + +mcp = FastMCP("interns") + + +def _get_api_key(endpoint_name: str, endpoints: dict[str, Any]) -> str: + env_var = endpoints[endpoint_name]["api_key_env"] + key = os.environ.get(env_var, "") + if not key: + raise RuntimeError(f"Missing API key: set {env_var} in {SECRETS_PATH}") + return key + + +def _build_extra_body(endpoint_cfg: dict[str, Any]) -> dict[str, Any]: + return endpoint_cfg.get("request_defaults", {}).get("extra_body", {}) + + +def register_tool(intern: Any, client_pool: ClientPool, endpoints: dict[str, Any]) -> None: + """Register a single intern as an MCP tool.""" + + @mcp.tool(name=intern.id, description=intern.description) + def intern_tool(paths: list[str], question: str, max_tokens: int | None = None) -> dict: + if intern.safety: + blocked = check_paths(paths) + if blocked: + return {"blocked": True, "path": blocked[0]["path"], "pattern": blocked[0]["pattern"], "reason": blocked[0]["reason"]} + + # Wire endpoint client if intern needs one. + if intern.endpoint and intern.client is None: + ep_cfg = endpoints[intern.endpoint] + api_key = _get_api_key(intern.endpoint, endpoints) + intern.client = client_pool.get(intern.endpoint, ep_cfg, api_key) + + kwargs: dict[str, Any] = {} + if intern.endpoint and intern.endpoint in endpoints: + kwargs["extra_body"] = _build_extra_body(endpoints[intern.endpoint]) + if max_tokens is not None: + kwargs["max_tokens"] = max_tokens + + result = intern.run(paths, question, **kwargs) + if isinstance(result, dict) and result.get("blocked"): + return result + return {"text": result.text, "usage": result.usage} + + +def register_grep_audit_tool(intern: Any) -> None: + """Register grep_audit with its distinct (paths, patterns, output, case_sensitive) shape. + + Deterministic: no LLM client wiring path. Safety gate still applies — server is + the single trust boundary regardless of whether the intern hits an endpoint. + """ + + @mcp.tool(name=intern.id, description=intern.description) + def grep_audit_tool( + paths: list[str], + patterns: list[Any], + output: str = "table", + case_sensitive: bool = True, + ) -> dict: + if intern.safety: + blocked = check_paths(paths) + if blocked: + return {"blocked": True, "path": blocked[0]["path"], "pattern": blocked[0]["pattern"], "reason": blocked[0]["reason"]} + + result = intern.run( + paths=paths, + patterns=patterns, + output=output, + case_sensitive=case_sensitive, + ) + return {"text": result.text, "usage": result.usage} + + +def main() -> None: + load_dotenv(SECRETS_PATH) + config = load_config(CONFIG_PATH) + endpoints = config.get("endpoints", {}) + + pool = ClientPool() + from . import safety as _safety_mod + + interns = load_interns(config, safety=_safety_mod, client=None) + for intern in interns: + if intern.id == "grep_audit": + register_grep_audit_tool(intern) + else: + register_tool(intern, pool, endpoints) + + mcp.run(transport="stdio") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/lib/interns-mcp/pyproject.toml b/lib/interns-mcp/pyproject.toml new file mode 100644 index 0000000..33398e6 --- /dev/null +++ b/lib/interns-mcp/pyproject.toml @@ -0,0 +1,18 @@ +[project] +name = "interns-mcp" +version = "0.3.3" +description = "FastMCP stdio server exposing cheap intern LLM tools for Claude Code" +requires-python = ">=3.11" +dependencies = [ + "fastmcp>=2.0", + "openai>=1.0", + "pyyaml>=6.0", + "python-dotenv>=1.0", +] + +[project.optional-dependencies] +dev = ["pytest>=8.0"] + +[build-system] +requires = ["setuptools>=68.0"] +build-backend = "setuptools.build_meta" \ No newline at end of file diff --git a/lib/interns-mcp/tests/test_client.py b/lib/interns-mcp/tests/test_client.py new file mode 100644 index 0000000..fe4bcac --- /dev/null +++ b/lib/interns-mcp/tests/test_client.py @@ -0,0 +1,21 @@ +"""Tests for HTTP client construction — esp. the request timeout that bounds +LLM calls so a stalled endpoint can never hang a worker thread forever.""" + +from interns_mcp.client import make_client, _resolve_timeout, DEFAULT_REQUEST_TIMEOUT + + +def test_default_timeout_is_bounded(): + # A bare endpoint config must still get a finite, short-ish timeout — + # the OpenAI SDK default (600s) is long enough to wedge the server. + assert _resolve_timeout({}) == DEFAULT_REQUEST_TIMEOUT + assert DEFAULT_REQUEST_TIMEOUT <= 120 + + +def test_configured_timeout_overrides_default(): + assert _resolve_timeout({"request_timeout": 42}) == 42.0 + + +def test_client_is_built_with_timeout(): + c = make_client({"base_url": "http://example.invalid", "request_timeout": 30}, "k") + # OpenAI client exposes the configured timeout; must not be None. + assert c.timeout is not None diff --git a/lib/interns-mcp/tests/test_grep_audit.py b/lib/interns-mcp/tests/test_grep_audit.py new file mode 100644 index 0000000..e4e1290 --- /dev/null +++ b/lib/interns-mcp/tests/test_grep_audit.py @@ -0,0 +1,237 @@ +"""Tests for grep_audit intern — deterministic, LLM-free.""" + +import json + +from interns_mcp.interns.base import Intern, InternResponse +from interns_mcp.interns.grep_audit import GrepAudit + + +def test_substring_case_sensitive(tmp_path): + f = tmp_path / "claude.md" + f.write_text("follow project discipline\nuse superpowers\n", encoding="utf-8") + + intern = GrepAudit() + result = intern.run( + paths=[str(f)], + patterns=["follow project discipline", "Follow Project Discipline"], + output="json", + ) + + assert isinstance(result, InternResponse) + data = json.loads(result.text) + matches = data["rows"][0]["matches"] + assert matches["follow project discipline"] is True + assert matches["Follow Project Discipline"] is False + + +def test_substring_case_insensitive(tmp_path): + f = tmp_path / "claude.md" + f.write_text("follow project discipline\n", encoding="utf-8") + + intern = GrepAudit() + result = intern.run( + paths=[str(f)], + patterns=["FOLLOW PROJECT DISCIPLINE"], + output="json", + case_sensitive=False, + ) + + data = json.loads(result.text) + assert data["rows"][0]["matches"]["FOLLOW PROJECT DISCIPLINE"] is True + + +def test_regex_named(tmp_path): + f = tmp_path / "pyproject.toml" + f.write_text('version = "0.3.0"\n', encoding="utf-8") + + intern = GrepAudit() + result = intern.run( + paths=[str(f)], + patterns=[{"pattern": r'version\s*=\s*"\d+\.\d+\.\d+"', "name": "version-line", "regex": True}], + output="json", + ) + + data = json.loads(result.text) + assert data["rows"][0]["matches"]["version-line"] is True + + +def test_dict_substring(tmp_path): + f = tmp_path / "skill.md" + f.write_text("trigger string here\n", encoding="utf-8") + + intern = GrepAudit() + result = intern.run( + paths=[str(f)], + patterns=[{"pattern": "trigger string", "name": "trigger"}], + output="json", + ) + + data = json.loads(result.text) + assert data["rows"][0]["matches"]["trigger"] is True + + +def test_output_table(tmp_path): + f1 = tmp_path / "a.md" + f1.write_text("alpha beta\n", encoding="utf-8") + f2 = tmp_path / "b.md" + f2.write_text("gamma\n", encoding="utf-8") + + intern = GrepAudit() + result = intern.run( + paths=[str(f1), str(f2)], + patterns=["alpha", "gamma"], + output="table", + ) + + text = result.text + assert text.splitlines()[0].startswith("| Path |") + assert "alpha" in text.splitlines()[0] + assert "gamma" in text.splitlines()[0] + # f1 has alpha but not gamma + f1_line = [l for l in text.splitlines() if str(f1) in l][0] + assert "✅" in f1_line + assert "❌" in f1_line + # f2 has gamma but not alpha + f2_line = [l for l in text.splitlines() if str(f2) in l][0] + assert "✅" in f2_line + assert "❌" in f2_line + + +def test_output_json(tmp_path): + f = tmp_path / "a.md" + f.write_text("alpha\n", encoding="utf-8") + + intern = GrepAudit() + result = intern.run( + paths=[str(f)], + patterns=["alpha", "missing"], + output="json", + ) + + data = json.loads(result.text) + assert "rows" in data + assert len(data["rows"]) == 1 + assert data["rows"][0]["path"] == str(f) + assert data["rows"][0]["matches"]["alpha"] is True + assert data["rows"][0]["matches"]["missing"] is False + + +def test_file_not_found_partial_result(tmp_path): + f_real = tmp_path / "exists.md" + f_real.write_text("hello\n", encoding="utf-8") + f_ghost = tmp_path / "ghost.md" # never created + + intern = GrepAudit() + result = intern.run( + paths=[str(f_real), str(f_ghost)], + patterns=["hello"], + output="json", + ) + + data = json.loads(result.text) + assert len(data["rows"]) == 2 # partial result, no abort + rows_by_path = {r["path"]: r for r in data["rows"]} + assert rows_by_path[str(f_real)]["matches"]["hello"] is True + # Ghost row: match is None (unreadable cell), error field present + assert rows_by_path[str(f_ghost)]["matches"]["hello"] is None + assert "error" in rows_by_path[str(f_ghost)] + + +def test_file_not_found_table_renders_warning(tmp_path): + f_ghost = tmp_path / "ghost.md" # never created + + intern = GrepAudit() + result = intern.run( + paths=[str(f_ghost)], + patterns=["x"], + output="table", + ) + + assert "⚠️" in result.text + + +def test_empty_paths(): + intern = GrepAudit() + result = intern.run(paths=[], patterns=["x"], output="json") + data = json.loads(result.text) + assert data["rows"] == [] + assert result.usage["files_scanned"] == 0 + assert result.usage["matches_total"] == 0 + + +def test_empty_patterns(tmp_path): + f = tmp_path / "a.md" + f.write_text("alpha\n", encoding="utf-8") + + intern = GrepAudit() + result = intern.run(paths=[str(f)], patterns=[], output="json") + data = json.loads(result.text) + assert len(data["rows"]) == 1 + assert data["rows"][0]["matches"] == {} + assert result.usage["patterns_evaluated"] == 0 + + +def test_unicode_content_utf8(tmp_path): + f = tmp_path / "ru.md" + f.write_text("русский текст\n", encoding="utf-8") + + intern = GrepAudit() + result = intern.run( + paths=[str(f)], + patterns=["русский"], + output="json", + ) + + data = json.loads(result.text) + assert data["rows"][0]["matches"]["русский"] is True + + +def test_unicode_content_binary_garbage_does_not_crash(tmp_path): + f = tmp_path / "binary.bin" + f.write_bytes(b"\xff\xfe\x00\x01\x02 hello \xff") + + intern = GrepAudit() + # errors="replace" should let us still grep for "hello" + result = intern.run( + paths=[str(f)], + patterns=["hello"], + output="json", + ) + + data = json.loads(result.text) + assert data["rows"][0]["matches"]["hello"] is True + + +def test_usage_counts(tmp_path): + f1 = tmp_path / "a.md" + f1.write_text("alpha\n", encoding="utf-8") + f2 = tmp_path / "b.md" + f2.write_text("alpha beta\n", encoding="utf-8") + + intern = GrepAudit() + result = intern.run( + paths=[str(f1), str(f2)], + patterns=["alpha", "beta"], + output="json", + ) + + assert result.usage["files_scanned"] == 2 + assert result.usage["patterns_evaluated"] == 2 + # f1: alpha ✓, beta ✗ → 1 + # f2: alpha ✓, beta ✓ → 2 + assert result.usage["matches_total"] == 3 + + +def test_endpoint_null_no_client_no_crash(): + """LLM-free intern must not require client wiring.""" + intern = GrepAudit(config={"endpoint": None}) + assert intern.client is None + assert intern.endpoint is None + + +def test_base_intern_accepts_endpoint_null_config(): + """Base class survives config with explicit endpoint=null (no LLM init triggered).""" + intern = Intern(config={"endpoint": None, "model": None}) + assert intern.endpoint is None + assert intern.model is None + assert intern.client is None diff --git a/lib/interns-mcp/tests/test_registry.py b/lib/interns-mcp/tests/test_registry.py new file mode 100644 index 0000000..825ae28 --- /dev/null +++ b/lib/interns-mcp/tests/test_registry.py @@ -0,0 +1,40 @@ +"""Tests for registry loading.""" + +from interns_mcp.registry import load_interns, INTERN_CLASSES + + +def test_classes_registered(): + assert "bulk_text_read" in INTERN_CLASSES + assert "repo_read" in INTERN_CLASSES + assert "transcript_distill" in INTERN_CLASSES + + +def test_load_creates_interns(): + config = { + "endpoints": {}, + "interns": { + "bulk_text_read": { + "description": "test", + "endpoint": None, + "model": "test-model", + "max_tokens": 1024, + "temperature": 0.1, + "system_prompt": "test prompt", + }, + }, + } + interns = load_interns(config) + assert len(interns) == 1 + assert interns[0].id == "bulk_text_read" + assert interns[0].model == "test-model" + + +def test_unknown_intern_skipped(): + config = { + "endpoints": {}, + "interns": { + "unknown_intern": {"description": "ghost"}, + }, + } + interns = load_interns(config) + assert len(interns) == 0 \ No newline at end of file diff --git a/lib/interns-mcp/tests/test_repo_read.py b/lib/interns-mcp/tests/test_repo_read.py new file mode 100644 index 0000000..c9e6a63 --- /dev/null +++ b/lib/interns-mcp/tests/test_repo_read.py @@ -0,0 +1,283 @@ +"""Tests for repo_read intern.""" + +import os +import subprocess +import tempfile +from pathlib import Path +from unittest.mock import MagicMock, patch + +from interns_mcp.interns.base import BudgetExceededError, InternResponse +from interns_mcp.interns.repo_read import RepoRead, _top_files_by_size + + +def _make_llm_mock(text="The code prints hello", prompt_tokens=100, completion_tokens=50): + """Build a mock LLM response.""" + mock = MagicMock() + mock.choices = [MagicMock()] + mock.choices[0].message.content = text + mock.usage.prompt_tokens = prompt_tokens + mock.usage.completion_tokens = completion_tokens + return mock + + +def test_happy_path(tmp_path): + """Repomix pack + LLM answer works.""" + intern = RepoRead() + intern.system_prompt = "You are a helpful assistant." + + mock_client = MagicMock() + mock_client.chat.completions.create.return_value = _make_llm_mock() + + target = str(tmp_path) + + with patch("subprocess.run") as mock_run, \ + patch("interns_mcp.interns.repo_read.always_ask_globs", return_value=[".env"]): + def fake_run(args, **kwargs): + out_idx = args.index("--output") + Path(args[out_idx + 1]).write_text("# Repo\n\n```python\nprint('hello')\n```") + return MagicMock(returncode=0, stdout="", stderr="") + + mock_run.side_effect = fake_run + + intern.client = mock_client + result = intern.run([target], "What does this repo do?") + + assert isinstance(result, InternResponse) + assert "hello" in result.text.lower() + assert result.usage["tokens_in"] == 100 + assert result.usage["tokens_out"] == 50 + + # Verify --ignore flag injected + call_args = mock_run.call_args[0][0] + assert "--ignore" in call_args + ignore_idx = call_args.index("--ignore") + assert call_args[ignore_idx + 1] == ".env" + + # Verify --style xml + assert "--style" in call_args + style_idx = call_args.index("--style") + assert call_args[style_idx + 1] == "xml" + + +def test_tempfile_cleaned_up(tmp_path): + """Temp file is removed after successful call.""" + intern = RepoRead() + intern.system_prompt = "assistant" + intern.client = MagicMock() + intern.client.chat.completions.create.return_value = _make_llm_mock() + + created_files = [] + + with patch("subprocess.run") as mock_run, \ + patch("interns_mcp.interns.repo_read.always_ask_globs", return_value=[]): + original_mkstemp = tempfile.mkstemp + + def tracking_mkstemp(*args, **kwargs): + fd, path = original_mkstemp(*args, **kwargs) + created_files.append(path) + return fd, path + + with patch("interns_mcp.interns.repo_read.tempfile.mkstemp", side_effect=tracking_mkstemp): + def fake_run(args, **kwargs): + out_idx = args.index("--output") + Path(args[out_idx + 1]).write_text("packed") + return MagicMock(returncode=0, stdout="", stderr="") + + mock_run.side_effect = fake_run + intern.run([str(tmp_path)], "summarize") + + # Temp file should be cleaned up + for f in created_files: + assert not os.path.exists(f), f"Temp file not cleaned up: {f}" + + +def test_tempfile_cleaned_up_on_error(tmp_path): + """Temp file is removed even when repomix fails.""" + intern = RepoRead() + + created_files = [] + + with patch("subprocess.run") as mock_run, \ + patch("interns_mcp.interns.repo_read.always_ask_globs", return_value=[]): + original_mkstemp = tempfile.mkstemp + + def tracking_mkstemp(*args, **kwargs): + fd, path = original_mkstemp(*args, **kwargs) + created_files.append(path) + return fd, path + + with patch("interns_mcp.interns.repo_read.tempfile.mkstemp", side_effect=tracking_mkstemp): + mock_run.side_effect = subprocess.TimeoutExpired("npx", 120) + intern.run([str(tmp_path)], "summarize") + + for f in created_files: + assert not os.path.exists(f), f"Temp file leaked on error: {f}" + + +def test_compress_flag_passed(tmp_path): + """compress=True adds --compress to repomix args.""" + intern = RepoRead() + intern.system_prompt = "assistant" + intern.client = MagicMock() + intern.client.chat.completions.create.return_value = _make_llm_mock() + + with patch("subprocess.run") as mock_run, \ + patch("interns_mcp.interns.repo_read.always_ask_globs", return_value=[]): + def fake_run(args, **kwargs): + out_idx = args.index("--output") + Path(args[out_idx + 1]).write_text("packed content") + return MagicMock(returncode=0, stdout="", stderr="") + + mock_run.side_effect = fake_run + + intern.run([str(tmp_path)], "summarize", compress=True) + + call_args = mock_run.call_args[0][0] + assert "--compress" in call_args + + +def test_multiple_paths_passed_to_repomix(tmp_path): + """All paths are forwarded to repomix, not just the first.""" + intern = RepoRead() + intern.system_prompt = "assistant" + intern.client = MagicMock() + intern.client.chat.completions.create.return_value = _make_llm_mock() + + dir1 = tmp_path / "a" + dir2 = tmp_path / "b" + dir1.mkdir() + dir2.mkdir() + + with patch("subprocess.run") as mock_run, \ + patch("interns_mcp.interns.repo_read.always_ask_globs", return_value=[]): + def fake_run(args, **kwargs): + out_idx = args.index("--output") + Path(args[out_idx + 1]).write_text("packed") + return MagicMock(returncode=0, stdout="", stderr="") + + mock_run.side_effect = fake_run + + intern.run([str(dir1), str(dir2)], "overview") + + call_args = mock_run.call_args[0][0] + assert str(dir1) in call_args + assert str(dir2) in call_args + + +def test_empty_paths_returns_error(): + """Empty paths list returns error.""" + intern = RepoRead() + result = intern.run([], "summarize") + assert isinstance(result, InternResponse) + assert "no paths" in result.text.lower() + + +def test_repomix_timeout_returns_error(tmp_path): + """Repomix timeout returns graceful error.""" + intern = RepoRead() + + with patch("subprocess.run", side_effect=subprocess.TimeoutExpired("npx", 120)), \ + patch("interns_mcp.interns.repo_read.always_ask_globs", return_value=[]): + result = intern.run([str(tmp_path)], "summarize") + assert isinstance(result, InternResponse) + assert "timed out" in result.text.lower() + + +def test_repomix_failure_returns_error(tmp_path): + """Repomix non-zero return returns stderr.""" + intern = RepoRead() + + with patch("subprocess.run") as mock_run, \ + patch("interns_mcp.interns.repo_read.always_ask_globs", return_value=[]): + mock_run.side_effect = subprocess.CalledProcessError( + 1, "npx", stderr="package not found" + ) + + result = intern.run([str(tmp_path)], "summarize") + assert isinstance(result, InternResponse) + assert "failed" in result.text.lower() + + +def test_npx_not_found_returns_error(tmp_path): + """Missing npx returns helpful message.""" + intern = RepoRead() + + with patch("subprocess.run", side_effect=FileNotFoundError), \ + patch("interns_mcp.interns.repo_read.always_ask_globs", return_value=[]): + result = intern.run([str(tmp_path)], "summarize") + assert isinstance(result, InternResponse) + assert "node.js" in result.text.lower() + + +def test_budget_exceeded_returns_error(tmp_path): + """Large packed output triggers BudgetExceededError.""" + intern = RepoRead() + intern.context_budget = 10 # very low to force overflow + + with patch("subprocess.run") as mock_run, \ + patch("interns_mcp.interns.repo_read.always_ask_globs", return_value=[]): + def fake_run(args, **kwargs): + out_idx = args.index("--output") + Path(args[out_idx + 1]).write_text("x" * 200) + return MagicMock(returncode=0, stdout="", stderr="") + + mock_run.side_effect = fake_run + + result = intern.run([str(tmp_path)], "summarize") + assert isinstance(result, BudgetExceededError) + assert result.tokens > result.limit + assert result.hint + + +def test_top_files_by_size_sorts_by_content_length(): + """_top_files_by_size returns files sorted by content size, not document order.""" + packed = ( + 'x\n' + '' + "y" * 500 + '\n' + '' + "z" * 100 + '\n' + ) + result = _top_files_by_size(packed, n=3) + assert result[0] == "large.py", f"Expected large.py first, got {result[0]}" + assert result[1] == "medium.py", f"Expected medium.py second, got {result[1]}" + assert result[2] == "small.py", f"Expected small.py third, got {result[2]}" + + +def test_repomix_runs_without_pipes(tmp_path): + """Anti-deadlock invariant: repomix must NOT be launched with captured pipes. + + capture_output / stdout=PIPE spawns reader threads that communicate() joins; + an orphaned grandchild holding the pipe write-end makes that join hang forever + (the real-world 6-minute repo_read wedge). stdout must be DEVNULL, stdin DEVNULL, + and stderr a real file (has fileno) — never subprocess.PIPE. + """ + intern = RepoRead() + intern.system_prompt = "assistant" + intern.client = MagicMock() + intern.client.chat.completions.create.return_value = _make_llm_mock() + + with patch("subprocess.run") as mock_run, \ + patch("interns_mcp.interns.repo_read.always_ask_globs", return_value=[]): + def fake_run(args, **kwargs): + out_idx = args.index("--output") + Path(args[out_idx + 1]).write_text("packed") + return MagicMock(returncode=0) + + mock_run.side_effect = fake_run + intern.run([str(tmp_path)], "summarize") + + kwargs = mock_run.call_args.kwargs + assert kwargs.get("capture_output") is not True, "must not capture_output (pipes)" + assert kwargs.get("stdout") is subprocess.DEVNULL, "stdout must be DEVNULL" + assert kwargs.get("stdin") is subprocess.DEVNULL, "stdin must be DEVNULL" + assert kwargs.get("stderr") is not subprocess.PIPE, "stderr must not be a PIPE" + + +def test_top_files_by_size_limits_count(): + """_top_files_by_size respects the n parameter.""" + packed = ( + 'xx\n' + 'yy\n' + 'zz\n' + ) + result = _top_files_by_size(packed, n=2) + assert len(result) == 2 \ No newline at end of file diff --git a/lib/interns-mcp/tests/test_safety.py b/lib/interns-mcp/tests/test_safety.py new file mode 100644 index 0000000..5d334b1 --- /dev/null +++ b/lib/interns-mcp/tests/test_safety.py @@ -0,0 +1,54 @@ +"""Tests for safety path matcher.""" + +from interns_mcp.safety import check_paths + + +def test_clean_paths_pass(): + assert check_paths(["/home/user/project/src/main.py"]) == [] + + +def test_env_file_blocked(): + blocked = check_paths(["project/.env"]) + assert len(blocked) == 1 + assert blocked[0]["pattern"] == "**/.env" + + +def test_env_local_blocked(): + blocked = check_paths(["project/.env.local"]) + assert len(blocked) == 1 + + +def test_secrets_dir_blocked(): + blocked = check_paths(["project/secrets/api_key.txt"]) + assert len(blocked) == 1 + + +def test_pem_file_blocked(): + blocked = check_paths(["/home/user/.ssh/id_rsa.pem"]) + assert len(blocked) == 1 + + +def test_ssh_dir_blocked(): + blocked = check_paths(["/home/user/.ssh/config"]) + assert len(blocked) == 1 + + +def test_credentials_blocked(): + blocked = check_paths(["project/credentials.json"]) + assert len(blocked) == 1 + + +def test_key_file_blocked(): + blocked = check_paths(["project/server.key"]) + assert len(blocked) == 1 + + +def test_mixed_paths(): + blocked = check_paths(["src/app.py", "project/.env", "README.md"]) + assert len(blocked) == 1 + assert blocked[0]["path"] == "project/.env" + + +def test_extra_patterns(): + blocked = check_paths(["data/private.csv"], extra_patterns=["data/**"]) + assert len(blocked) == 1 \ No newline at end of file diff --git a/lib/projects-meta-mcp/.gitignore b/lib/projects-meta-mcp/.gitignore new file mode 100644 index 0000000..b923cf5 --- /dev/null +++ b/lib/projects-meta-mcp/.gitignore @@ -0,0 +1,11 @@ +node_modules/ +dist/ +build/ +*.log +.env +.env.local +auth.toml +.DS_Store +Thumbs.db +.vscode/ +.idea/ diff --git a/lib/projects-meta-mcp/CLAUDE.md b/lib/projects-meta-mcp/CLAUDE.md new file mode 100644 index 0000000..8e9866d --- /dev/null +++ b/lib/projects-meta-mcp/CLAUDE.md @@ -0,0 +1,11 @@ +# CLAUDE.md +# Agent instructions. Each line is a trigger for an installed skill. + +talk like a caveman +use superpowers +use project wiki +use task management system +check across all projects +pull remote before work +follow project discipline +we're on Windows diff --git a/lib/projects-meta-mcp/README.md b/lib/projects-meta-mcp/README.md new file mode 100644 index 0000000..270c4fb --- /dev/null +++ b/lib/projects-meta-mcp/README.md @@ -0,0 +1,74 @@ +# projects-meta-mcp + +MCP-сервер: агрегирует статусы задач (`.tasks/STATUS.md`) и общие знания (репо `projects-wiki`, локально клонится в `~/projects/.wiki/`) поверх множества проектов и нескольких машин. + +- Источник правды: Gitea (`https://git.kzntsv.site`, owner `OpeItcLoc03`). +- Процесс — локальный на каждой машине (offline-критично). +- Данные — локальный кэш + клон `projects-wiki`, синхронизируются скриптом. + +## Bootstrap + +```bash +git clone https://git.kzntsv.site/OpeItcLoc03/projects-meta-mcp ~/.local/projects-meta-mcp +cd ~/.local/projects-meta-mcp +npm install && npm run build + +git clone https://git.kzntsv.site/OpeItcLoc03/projects-wiki ~/projects/.wiki + +mkdir -p ~/.config/projects-mcp +cp auth.toml.example ~/.config/projects-mcp/auth.toml +# отредактировать auth.toml — заполнить gitea_token + +node dist/sync.js # первый sync +``` + +## Регистрация в Claude Code + +Добавить в `~/.claude.json` (расположение файла см. ниже для вашей ОС): + +### Windows (PowerShell) + +```json +{ + "mcpServers": { + "projects-meta": { + "command": "node", + "args": ["C:\\Users\\\\.local\\projects-meta-mcp\\dist\\server.js"] + } + } +} +``` + +Файл расположен по адресу: `%USERPROFILE%\.claude.json` + +### Linux / macOS + +```json +{ + "mcpServers": { + "projects-meta": { + "command": "node", + "args": ["/home//.local/projects-meta-mcp/dist/server.js"] + } + } +} +``` + +Файл расположен по адресу: `~/.claude.json` + +**Примечание:** замените `` на ваше имя пользователя. + +## Tools + +| Tool | Назначение | +|------|------------| +| `tasks.aggregate` | Свод активных задач по всем проектам | +| `tasks.search` | Поиск по slug/next-action | +| `tasks.get` | Полный STATUS.md одного проекта | +| `knowledge.search` | Поиск по shared-вики, фильтр по домену cwd | +| `knowledge.get` | Полный текст одной страницы | +| `knowledge.suggest_promote` | Кандидаты на промоушен из `.wiki/concepts/` текущего проекта | +| `meta.status` | Возраст кэша + диагностика | + +См. дизайн: [docs/superpowers/specs/2026-04-29-projects-meta-mcp-design.md](docs/superpowers/specs/2026-04-29-projects-meta-mcp-design.md) +План реализации: [docs/superpowers/plans/2026-04-29-bootstrap-implementation.md](docs/superpowers/plans/2026-04-29-bootstrap-implementation.md) diff --git a/lib/projects-meta-mcp/auth.toml.example b/lib/projects-meta-mcp/auth.toml.example new file mode 100644 index 0000000..5f29fc5 --- /dev/null +++ b/lib/projects-meta-mcp/auth.toml.example @@ -0,0 +1,31 @@ +# projects-meta-mcp auth config +# Copy to ~/.config/projects-mcp/auth.toml and fill gitea_token. + +gitea_url = "https://git.kzntsv.site" +gitea_user = "OpeItcLoc03" # acting identity for commits +gitea_token = "REPLACE_WITH_PERSONAL_ACCESS_TOKEN" # scope: read:repository (write:repository for tasks.create / knowledge.ingest) + +# Optional: Gitea owners (users / orgs) sync iterates over to discover repos. +# Defaults to [gitea_user] when absent or empty. Add organisations here to +# aggregate cross-organisation projects under one acting identity. +# gitea_owners = ["victor", "cancel_music"] + +# Optional: owners synced into the cache (so MCP-mutate tools can target them) +# but **hidden** from aggregation views (`tasks.aggregate / tasks.search / +# tasks.get`). Use this for infra repos you want to write to via MCP without +# polluting the cross-project dashboard. Default: []. +# +# Example: keep `OpeItcLoc03/{common,claude-skills,factory,...}` resolvable for +# `tasks.create target_project: "OpeItcLoc03/common"` but not in aggregation: +# gitea_aggregate_skip_owners = ["OpeItcLoc03"] + +# Optional: name of the Gitea repo holding the cross-project agenda tasks board. +# Accepts bare name (resolved against gitea_user) or qualified "/". +# Default: "projects-tasks". If the repo doesn't exist (or has no STATUS.md), +# sync falls back to ~/projects/.tasks/STATUS.md on the local FS. +# agenda_tasks_repo = "OpeItcLoc03/agenda" + +# Optional: name of the Gitea repo holding the cross-project shared wiki. +# Default: "projects-wiki". Used as the destination when knowledge.ingest / +# knowledge.promote receive `target_project = "agenda"`. +# projects_wiki_repo = "projects-wiki" diff --git a/lib/projects-meta-mcp/docs/superpowers/plans/2026-04-29-bootstrap-implementation.md b/lib/projects-meta-mcp/docs/superpowers/plans/2026-04-29-bootstrap-implementation.md new file mode 100644 index 0000000..6a122d5 --- /dev/null +++ b/lib/projects-meta-mcp/docs/superpowers/plans/2026-04-29-bootstrap-implementation.md @@ -0,0 +1,3087 @@ +# projects-meta-mcp Bootstrap Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Реализовать `sync-script` (Gitea API → JSON-кэш) и stdio MCP-сервер с тулами `tasks.*`, `knowledge.*`, `meta.status` — согласно спеке [2026-04-29-projects-meta-mcp-design.md](../specs/2026-04-29-projects-meta-mcp-design.md). Конечное состояние: после `npm run build` и `node dist/sync.js` локальный `~/.cache/projects-mcp/tasks.json` содержит реальные `STATUS.md` всех проектов owner'a `OpeItcLoc03`, `node dist/server.js` стартует stdio MCP и отвечает на все определённые tools. + +**Architecture:** Один TS-пакет, два бинарника: `dist/sync.js` (CLI, делает сетевые вызовы) и `dist/server.js` (MCP stdio, читает только локальные файлы). Общая логика (парсеры, конфиг, кэш) — в `src/lib/`. ESM (`"type": "module"`), TypeScript NodeNext, native fetch. + +**Tech Stack:** Node 22, TypeScript 5, `@modelcontextprotocol/sdk`, `gray-matter` (frontmatter), `smol-toml` (auth.toml), `zod` (схемы tool-инпутов), `vitest` (тесты). + +--- + +## File structure + +``` +projects-meta-mcp/ +├── package.json +├── tsconfig.json +├── vitest.config.ts +├── auth.toml.example +├── .gitignore # дополнить +├── src/ +│ ├── sync.ts # CLI entry: npm run sync +│ ├── server.ts # MCP entry: npm run start +│ ├── lib/ +│ │ ├── config.ts # paths + auth.toml loader +│ │ ├── cache.ts # atomic JSON write/read +│ │ ├── gitea.ts # fetch wrapper for Gitea API +│ │ ├── status-md.ts # parse STATUS.md → ParsedStatus +│ │ ├── domain-detector.ts # detect cwd domain +│ │ ├── frontmatter.ts # parse wiki page frontmatter +│ │ └── promotion.ts # candidate heuristic +│ └── tools/ +│ ├── tasks.ts # tasks.aggregate/search/get handlers +│ ├── knowledge.ts # knowledge.search/get/suggest_promote +│ └── meta.ts # meta.status +└── tests/ + └── (mirrors src/lib/ and src/tools/) +``` + +**File size discipline:** `lib/` files ≤ 150 lines each. Если файл вырастает — split. + +**Commits:** один TDD-цикл (test → impl → green → commit) = один коммит. Сообщения в Conventional Commits, scope = file/module. + +--- + +## Phase 1 — Bootstrap + +### Task 1.1: Initialize npm package + TypeScript + +**Files:** +- Create: `package.json` +- Create: `tsconfig.json` +- Create: `vitest.config.ts` +- Create: `src/sync.ts` (skeleton) +- Create: `src/server.ts` (skeleton) + +- [ ] **Step 1: Create package.json** + +```json +{ + "name": "projects-meta-mcp", + "version": "0.1.0", + "private": true, + "type": "module", + "bin": { + "projects-meta-mcp": "dist/server.js", + "projects-meta-sync": "dist/sync.js" + }, + "scripts": { + "build": "tsc", + "start": "node dist/server.js", + "sync": "node dist/sync.js", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.0", + "gray-matter": "^4.0.3", + "smol-toml": "^1.3.0", + "zod": "^3.23.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0", + "vitest": "^2.1.0" + }, + "engines": { + "node": ">=22" + } +} +``` + +- [ ] **Step 2: Create tsconfig.json** + +```json +{ + "compilerOptions": { + "target": "ES2023", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2023"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": false, + "sourceMap": true, + "resolveJsonModule": true, + "verbatimModuleSyntax": true + }, + "include": ["src/**/*"], + "exclude": ["dist", "node_modules", "tests"] +} +``` + +- [ ] **Step 3: Create vitest.config.ts** + +```ts +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['tests/**/*.test.ts'], + environment: 'node', + }, +}); +``` + +- [ ] **Step 4: Create src/sync.ts and src/server.ts skeletons** + +`src/sync.ts`: +```ts +async function main(): Promise { + console.log('sync: not implemented yet'); +} + +await main(); +``` + +`src/server.ts`: +```ts +async function main(): Promise { + console.error('server: not implemented yet'); +} + +await main(); +``` + +- [ ] **Step 5: Run `npm install`** + +Run: `npm install` +Expected: `node_modules/` populated, `package-lock.json` created, no errors. + +- [ ] **Step 6: Verify build works** + +Run: `npm run build && node dist/sync.js && node dist/server.js` +Expected: skeleton outputs without crash. `dist/` contains `.js` files. + +- [ ] **Step 7: Add dist/ and node_modules to .gitignore (already there)** + +Verify `.gitignore` already contains `node_modules/` and `dist/`. If not, add. + +- [ ] **Step 8: Commit** + +```bash +git add package.json package-lock.json tsconfig.json vitest.config.ts src/sync.ts src/server.ts +git commit -m "chore: scaffold TS package with sync + server entries" +``` + +--- + +### Task 1.2: Auth example + cache dir convention + +**Files:** +- Create: `auth.toml.example` + +- [ ] **Step 1: Create auth.toml.example** + +```toml +# projects-meta-mcp auth config +# Copy to ~/.config/projects-mcp/auth.toml and fill gitea_token. + +gitea_url = "https://git.kzntsv.site" +gitea_user = "OpeItcLoc03" +gitea_token = "REPLACE_WITH_PERSONAL_ACCESS_TOKEN" # scope: read:repository +``` + +- [ ] **Step 2: Commit** + +```bash +git add auth.toml.example +git commit -m "chore: add auth.toml.example" +``` + +--- + +## Phase 2 — sync-script + +### Task 2.1: Config loader + +**Files:** +- Create: `src/lib/config.ts` +- Create: `tests/lib/config.test.ts` + +**Contract:** +```ts +export interface AuthConfig { + giteaUrl: string; // no trailing slash + giteaUser: string; + giteaToken: string; +} + +export interface Paths { + cacheDir: string; // ~/.cache/projects-mcp + cacheFile: string; // /tasks.json + syncLog: string; // /sync.log + authFile: string; // ~/.config/projects-mcp/auth.toml + sharedWikiClone: string; // ~/projects/.wiki +} + +export function getPaths(home: string): Paths; +export function loadAuth(authFile: string): Promise; // throws on missing/malformed +``` + +- [ ] **Step 1: Write failing tests** + +`tests/lib/config.test.ts`: +```ts +import { describe, it, expect } from 'vitest'; +import { mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { getPaths, loadAuth } from '../../src/lib/config.js'; + +describe('getPaths', () => { + it('builds canonical paths from $HOME', () => { + const p = getPaths('/home/u'); + expect(p.cacheDir).toBe('/home/u/.cache/projects-mcp'); + expect(p.cacheFile).toBe('/home/u/.cache/projects-mcp/tasks.json'); + expect(p.authFile).toBe('/home/u/.config/projects-mcp/auth.toml'); + expect(p.sharedWikiClone).toBe('/home/u/projects/.wiki'); + }); +}); + +describe('loadAuth', () => { + it('parses well-formed auth.toml', async () => { + const dir = await mkdtemp(join(tmpdir(), 'auth-')); + const file = join(dir, 'auth.toml'); + await writeFile( + file, + [ + 'gitea_url = "https://git.kzntsv.site"', + 'gitea_user = "OpeItcLoc03"', + 'gitea_token = "abc123"', + ].join('\n'), + ); + const cfg = await loadAuth(file); + expect(cfg.giteaUrl).toBe('https://git.kzntsv.site'); + expect(cfg.giteaUser).toBe('OpeItcLoc03'); + expect(cfg.giteaToken).toBe('abc123'); + }); + + it('strips trailing slash from gitea_url', async () => { + const dir = await mkdtemp(join(tmpdir(), 'auth-')); + const file = join(dir, 'auth.toml'); + await writeFile( + file, + 'gitea_url = "https://git.kzntsv.site/"\ngitea_user = "x"\ngitea_token = "y"\n', + ); + const cfg = await loadAuth(file); + expect(cfg.giteaUrl).toBe('https://git.kzntsv.site'); + }); + + it('throws on missing field', async () => { + const dir = await mkdtemp(join(tmpdir(), 'auth-')); + const file = join(dir, 'auth.toml'); + await writeFile(file, 'gitea_url = "x"\ngitea_user = "y"\n'); + await expect(loadAuth(file)).rejects.toThrow(/gitea_token/); + }); +}); +``` + +- [ ] **Step 2: Run, expect FAIL** + +Run: `npm test -- tests/lib/config.test.ts` +Expected: FAIL — no `src/lib/config.ts`. + +- [ ] **Step 3: Implement src/lib/config.ts** + +```ts +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { parse as parseToml } from 'smol-toml'; +import { z } from 'zod'; + +export interface AuthConfig { + giteaUrl: string; + giteaUser: string; + giteaToken: string; +} + +export interface Paths { + cacheDir: string; + cacheFile: string; + syncLog: string; + authFile: string; + sharedWikiClone: string; +} + +export function getPaths(home: string): Paths { + const cacheDir = join(home, '.cache', 'projects-mcp'); + return { + cacheDir, + cacheFile: join(cacheDir, 'tasks.json'), + syncLog: join(cacheDir, 'sync.log'), + authFile: join(home, '.config', 'projects-mcp', 'auth.toml'), + sharedWikiClone: join(home, 'projects', '.wiki'), + }; +} + +const AuthSchema = z.object({ + gitea_url: z.string().min(1), + gitea_user: z.string().min(1), + gitea_token: z.string().min(1), +}); + +export async function loadAuth(authFile: string): Promise { + const raw = await readFile(authFile, 'utf8'); + const parsed = AuthSchema.parse(parseToml(raw)); + return { + giteaUrl: parsed.gitea_url.replace(/\/+$/, ''), + giteaUser: parsed.gitea_user, + giteaToken: parsed.gitea_token, + }; +} +``` + +- [ ] **Step 4: Run, expect PASS** + +Run: `npm test -- tests/lib/config.test.ts` +Expected: 3 passed. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/config.ts tests/lib/config.test.ts +git commit -m "feat(config): add path resolver + auth.toml loader" +``` + +--- + +### Task 2.2: Atomic cache writer + +**Files:** +- Create: `src/lib/cache.ts` +- Create: `tests/lib/cache.test.ts` + +**Contract:** +```ts +export interface ProjectStatus { + name: string; + default_branch: string; + fetched_at: string; // ISO + active_tasks: ActiveTask[]; + all_tasks_count: number; + raw: string; // full STATUS.md +} + +export interface ActiveTask { + slug: string; + status: 'active' | 'paused' | 'blocked' | 'ready' | 'done'; + next: string | null; +} + +export interface SyncError { + project: string; + reason: string; // free-form +} + +export interface CacheFile { + synced_at: string; + synced_from: string; + machine: string; + projects: ProjectStatus[]; + errors: SyncError[]; +} + +export async function writeCache(file: string, data: CacheFile): Promise; +export async function readCache(file: string): Promise; // null when missing +``` + +- [ ] **Step 1: Write failing tests** + +`tests/lib/cache.test.ts`: +```ts +import { describe, it, expect } from 'vitest'; +import { mkdtemp, readFile, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { writeCache, readCache, type CacheFile } from '../../src/lib/cache.js'; + +const sample: CacheFile = { + synced_at: '2026-04-29T12:00:00.000Z', + synced_from: 'https://git.kzntsv.site', + machine: 'test', + projects: [], + errors: [], +}; + +describe('cache', () => { + it('writes JSON atomically and reads it back', async () => { + const dir = await mkdtemp(join(tmpdir(), 'cache-')); + const file = join(dir, 'tasks.json'); + await writeCache(file, sample); + const back = await readCache(file); + expect(back).toEqual(sample); + }); + + it('does not leave .tmp file on success', async () => { + const dir = await mkdtemp(join(tmpdir(), 'cache-')); + const file = join(dir, 'tasks.json'); + await writeCache(file, sample); + await expect(stat(file + '.tmp')).rejects.toThrow(); + }); + + it('returns null when cache file missing', async () => { + const dir = await mkdtemp(join(tmpdir(), 'cache-')); + const back = await readCache(join(dir, 'missing.json')); + expect(back).toBeNull(); + }); + + it('creates parent directory if missing', async () => { + const dir = await mkdtemp(join(tmpdir(), 'cache-')); + const file = join(dir, 'nested', 'deep', 'tasks.json'); + await writeCache(file, sample); + const txt = await readFile(file, 'utf8'); + expect(JSON.parse(txt)).toEqual(sample); + }); +}); +``` + +- [ ] **Step 2: Run, expect FAIL** + +Run: `npm test -- tests/lib/cache.test.ts` +Expected: FAIL — no module. + +- [ ] **Step 3: Implement src/lib/cache.ts** + +```ts +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +export interface ActiveTask { + slug: string; + status: 'active' | 'paused' | 'blocked' | 'ready' | 'done'; + next: string | null; +} + +export interface ProjectStatus { + name: string; + default_branch: string; + fetched_at: string; + active_tasks: ActiveTask[]; + all_tasks_count: number; + raw: string; +} + +export interface SyncError { + project: string; + reason: string; +} + +export interface CacheFile { + synced_at: string; + synced_from: string; + machine: string; + projects: ProjectStatus[]; + errors: SyncError[]; +} + +export async function writeCache(file: string, data: CacheFile): Promise { + await mkdir(dirname(file), { recursive: true }); + const tmp = `${file}.tmp`; + await writeFile(tmp, JSON.stringify(data, null, 2), 'utf8'); + await rename(tmp, file); +} + +export async function readCache(file: string): Promise { + try { + const txt = await readFile(file, 'utf8'); + return JSON.parse(txt) as CacheFile; + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw err; + } +} +``` + +- [ ] **Step 4: Run, expect PASS** + +Run: `npm test -- tests/lib/cache.test.ts` +Expected: 4 passed. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/cache.ts tests/lib/cache.test.ts +git commit -m "feat(cache): atomic tasks.json read/write" +``` + +--- + +### Task 2.3: STATUS.md parser + +**Files:** +- Create: `src/lib/status-md.ts` +- Create: `tests/lib/status-md.test.ts` + +**Contract:** +```ts +export interface ParsedTask { + slug: string; + status: 'active' | 'paused' | 'blocked' | 'ready' | 'done'; + description: string; + next: string | null; +} + +export interface ParsedStatus { + updated: string | null; + tasks: ParsedTask[]; +} + +export function parseStatusMd(text: string): ParsedStatus; +``` + +**Format reference:** Each task starts with `## [] — `. Body has bold-prefixed fields. Emoji map: 🔴=active, 🟡=paused, ⚪=ready, 🟢=done, 🔵=blocked. + +- [ ] **Step 1: Write failing tests** + +`tests/lib/status-md.test.ts`: +```ts +import { describe, it, expect } from 'vitest'; +import { parseStatusMd } from '../../src/lib/status-md.js'; + +const sample = `# Task Board +_Updated: 2026-04-29_ + +## 🔴 [auth-rewrite] — Rebuild OAuth pipeline +**Status:** active +**Where I stopped:** Mid-refactor of token validator +**Next action:** Add unit tests for refresh flow +**Branch:** feat/auth-rewrite + +--- + +## 🟡 [docs-sweep] — Refresh README +**Status:** paused +**Where I stopped:** Drafted intro, blocked on screenshots +**Next action:** Capture login screencast +**Branch:** docs/readme + +--- + +## 🔵 [vendor-bug] — Upstream pagination broken +**Status:** blocked +**Blocker:** Waiting on vendor patch v2.4.1 +**Next action:** Re-run integration test once patched +**Branch:** fix/vendor-bug + +--- +`; + +describe('parseStatusMd', () => { + it('extracts updated timestamp', () => { + const r = parseStatusMd(sample); + expect(r.updated).toBe('2026-04-29'); + }); + + it('parses three tasks with correct statuses', () => { + const r = parseStatusMd(sample); + expect(r.tasks.map((t) => t.slug)).toEqual([ + 'auth-rewrite', + 'docs-sweep', + 'vendor-bug', + ]); + expect(r.tasks.map((t) => t.status)).toEqual(['active', 'paused', 'blocked']); + }); + + it('captures description and next action', () => { + const r = parseStatusMd(sample); + expect(r.tasks[0].description).toBe('Rebuild OAuth pipeline'); + expect(r.tasks[0].next).toBe('Add unit tests for refresh flow'); + }); + + it('returns empty tasks when board empty', () => { + const r = parseStatusMd('# Task Board\n_Updated: 2026-04-29_\n'); + expect(r.tasks).toEqual([]); + expect(r.updated).toBe('2026-04-29'); + }); + + it('returns null updated when missing', () => { + expect(parseStatusMd('# Task Board\n').updated).toBeNull(); + }); + + it('treats unknown emoji as ready (best effort)', () => { + const txt = '## ⚫ [weird-task] — desc\n**Next action:** something\n'; + const r = parseStatusMd(txt); + expect(r.tasks[0].status).toBe('ready'); + }); +}); +``` + +- [ ] **Step 2: Run, expect FAIL** + +Run: `npm test -- tests/lib/status-md.test.ts` +Expected: FAIL — module missing. + +- [ ] **Step 3: Implement src/lib/status-md.ts** + +```ts +export type TaskStatus = 'active' | 'paused' | 'blocked' | 'ready' | 'done'; + +export interface ParsedTask { + slug: string; + status: TaskStatus; + description: string; + next: string | null; +} + +export interface ParsedStatus { + updated: string | null; + tasks: ParsedTask[]; +} + +const EMOJI_TO_STATUS: Record = { + '🔴': 'active', + '🟡': 'paused', + '⚪': 'ready', + '🟢': 'done', + '🔵': 'blocked', +}; + +const TASK_HEADER = /^##\s+(\S+)\s+\[([^\]]+)\]\s+—\s+(.+)$/u; +const NEXT_FIELD = /^\*\*Next action:\*\*\s*(.+)$/i; +const UPDATED_FIELD = /^_Updated:\s*([0-9-]+)_/i; + +export function parseStatusMd(text: string): ParsedStatus { + const lines = text.split(/\r?\n/); + let updated: string | null = null; + const tasks: ParsedTask[] = []; + let current: ParsedTask | null = null; + + for (const line of lines) { + if (!updated) { + const m = line.match(UPDATED_FIELD); + if (m) updated = m[1]; + } + + const head = line.match(TASK_HEADER); + if (head) { + if (current) tasks.push(current); + const [, emoji, slug, description] = head; + current = { + slug, + status: EMOJI_TO_STATUS[emoji] ?? 'ready', + description: description.trim(), + next: null, + }; + continue; + } + + if (current) { + const m = line.match(NEXT_FIELD); + if (m) current.next = m[1].trim(); + } + } + + if (current) tasks.push(current); + return { updated, tasks }; +} +``` + +- [ ] **Step 4: Run, expect PASS** + +Run: `npm test -- tests/lib/status-md.test.ts` +Expected: 6 passed. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/status-md.ts tests/lib/status-md.test.ts +git commit -m "feat(status-md): parser for task board format" +``` + +--- + +### Task 2.4: Gitea API client + +**Files:** +- Create: `src/lib/gitea.ts` +- Create: `tests/lib/gitea.test.ts` + +**Contract:** +```ts +export interface GiteaRepo { + name: string; + default_branch: string; +} + +export interface GiteaClient { + listUserRepos(user: string): Promise; + getRawFile(user: string, repo: string, path: string, branch: string): Promise; + // returns null on 404; throws on network/5xx/auth errors +} + +export function makeGiteaClient(opts: { + baseUrl: string; + token: string; + fetchImpl?: typeof fetch; +}): GiteaClient; +``` + +- [ ] **Step 1: Write failing tests** + +`tests/lib/gitea.test.ts`: +```ts +import { describe, it, expect } from 'vitest'; +import { makeGiteaClient } from '../../src/lib/gitea.js'; + +function mockFetch(handler: (url: string, init?: RequestInit) => Response | Promise) { + return async (input: string | URL | Request, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input.toString(); + return handler(url, init); + }; +} + +describe('GiteaClient', () => { + it('lists user repos with token in header', async () => { + let receivedAuth = ''; + const fetchImpl = mockFetch((url, init) => { + receivedAuth = (init?.headers as Record)['Authorization']; + expect(url).toBe('https://g/api/v1/users/u/repos?limit=50'); + return new Response( + JSON.stringify([ + { name: 'r1', default_branch: 'main' }, + { name: 'r2', default_branch: 'master' }, + ]), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + }); + const c = makeGiteaClient({ baseUrl: 'https://g', token: 't', fetchImpl: fetchImpl as never }); + const repos = await c.listUserRepos('u'); + expect(repos).toEqual([ + { name: 'r1', default_branch: 'main' }, + { name: 'r2', default_branch: 'master' }, + ]); + expect(receivedAuth).toBe('token t'); + }); + + it('paginates listUserRepos', async () => { + const pages = [ + [{ name: 'r1', default_branch: 'main' }, { name: 'r2', default_branch: 'main' }], + [{ name: 'r3', default_branch: 'main' }], + [], + ]; + let i = 0; + const fetchImpl = mockFetch(() => { + const body = JSON.stringify(pages[i++]); + return new Response(body, { status: 200, headers: { 'content-type': 'application/json' } }); + }); + const c = makeGiteaClient({ baseUrl: 'https://g', token: 't', fetchImpl: fetchImpl as never }); + const repos = await c.listUserRepos('u'); + expect(repos.map((r) => r.name)).toEqual(['r1', 'r2', 'r3']); + }); + + it('returns null on 404 raw file', async () => { + const fetchImpl = mockFetch(() => new Response('Not Found', { status: 404 })); + const c = makeGiteaClient({ baseUrl: 'https://g', token: 't', fetchImpl: fetchImpl as never }); + const r = await c.getRawFile('u', 'repo', '.tasks/STATUS.md', 'main'); + expect(r).toBeNull(); + }); + + it('returns body on 200 raw file', async () => { + const fetchImpl = mockFetch((url) => { + expect(url).toBe('https://g/api/v1/repos/u/repo/raw/.tasks/STATUS.md?ref=main'); + return new Response('# Task Board\n', { status: 200 }); + }); + const c = makeGiteaClient({ baseUrl: 'https://g', token: 't', fetchImpl: fetchImpl as never }); + const r = await c.getRawFile('u', 'repo', '.tasks/STATUS.md', 'main'); + expect(r).toBe('# Task Board\n'); + }); + + it('throws on 500', async () => { + const fetchImpl = mockFetch(() => new Response('boom', { status: 500 })); + const c = makeGiteaClient({ baseUrl: 'https://g', token: 't', fetchImpl: fetchImpl as never }); + await expect(c.getRawFile('u', 'r', 'p', 'main')).rejects.toThrow(/500/); + }); + + it('throws on 401', async () => { + const fetchImpl = mockFetch(() => new Response('nope', { status: 401 })); + const c = makeGiteaClient({ baseUrl: 'https://g', token: 't', fetchImpl: fetchImpl as never }); + await expect(c.listUserRepos('u')).rejects.toThrow(/401/); + }); +}); +``` + +- [ ] **Step 2: Run, expect FAIL** + +Run: `npm test -- tests/lib/gitea.test.ts` +Expected: FAIL — module missing. + +- [ ] **Step 3: Implement src/lib/gitea.ts** + +```ts +export interface GiteaRepo { + name: string; + default_branch: string; +} + +export interface GiteaClient { + listUserRepos(user: string): Promise; + getRawFile( + user: string, + repo: string, + path: string, + branch: string, + ): Promise; +} + +interface Options { + baseUrl: string; + token: string; + fetchImpl?: typeof fetch; +} + +const PAGE_SIZE = 50; + +export function makeGiteaClient(opts: Options): GiteaClient { + const fetchImpl = opts.fetchImpl ?? fetch; + const baseUrl = opts.baseUrl.replace(/\/+$/, ''); + const headers = { Authorization: `token ${opts.token}` }; + + async function call(url: string): Promise { + const res = await fetchImpl(url, { headers }); + return res; + } + + return { + async listUserRepos(user: string): Promise { + const repos: GiteaRepo[] = []; + let page = 1; + while (true) { + const url = + `${baseUrl}/api/v1/users/${encodeURIComponent(user)}/repos?limit=${PAGE_SIZE}` + + (page > 1 ? `&page=${page}` : ''); + const res = await call(url); + if (!res.ok) { + throw new Error(`Gitea listUserRepos ${res.status}: ${await res.text()}`); + } + const batch = (await res.json()) as Array<{ name: string; default_branch: string }>; + if (batch.length === 0) break; + for (const r of batch) repos.push({ name: r.name, default_branch: r.default_branch }); + if (batch.length < PAGE_SIZE) break; + page += 1; + } + return repos; + }, + + async getRawFile(user, repo, path, branch) { + const url = `${baseUrl}/api/v1/repos/${encodeURIComponent(user)}/${encodeURIComponent( + repo, + )}/raw/${path}?ref=${encodeURIComponent(branch)}`; + const res = await call(url); + if (res.status === 404) return null; + if (!res.ok) { + throw new Error(`Gitea getRawFile ${res.status}: ${await res.text()}`); + } + return await res.text(); + }, + }; +} +``` + +- [ ] **Step 4: Run, expect PASS** + +Run: `npm test -- tests/lib/gitea.test.ts` +Expected: 6 passed. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/gitea.ts tests/lib/gitea.test.ts +git commit -m "feat(gitea): list-repos + raw-file client with pagination" +``` + +--- + +### Task 2.5: Sync orchestrator + +**Files:** +- Create: `src/lib/sync-runner.ts` +- Create: `tests/lib/sync-runner.test.ts` + +**Contract:** +```ts +export interface SyncDeps { + client: GiteaClient; + parseStatus: (raw: string) => ParsedStatus; + now: () => Date; + machine: string; + giteaUrl: string; + user: string; + concurrency?: number; // default 10 +} + +export async function runSync(deps: SyncDeps): Promise; +``` + +- [ ] **Step 1: Write failing tests** + +`tests/lib/sync-runner.test.ts`: +```ts +import { describe, it, expect } from 'vitest'; +import { runSync } from '../../src/lib/sync-runner.js'; +import { parseStatusMd } from '../../src/lib/status-md.js'; +import type { GiteaClient } from '../../src/lib/gitea.js'; + +function fakeClient(repos: Array<{ name: string; default_branch: string }>, files: Record): GiteaClient { + return { + async listUserRepos() { + return repos; + }, + async getRawFile(_u, repo) { + const v = files[repo]; + if (v instanceof Error) throw v; + return v ?? null; + }, + }; +} + +describe('runSync', () => { + const now = () => new Date('2026-04-29T12:00:00Z'); + const sample = `# Task Board\n_Updated: 2026-04-29_\n\n## 🔴 [t1] — first\n**Next action:** do x\n`; + + it('aggregates STATUS.md across repos', async () => { + const cache = await runSync({ + client: fakeClient( + [{ name: 'a', default_branch: 'main' }, { name: 'b', default_branch: 'main' }], + { a: sample, b: sample }, + ), + parseStatus: parseStatusMd, + now, + machine: 'm', + giteaUrl: 'https://g', + user: 'u', + }); + expect(cache.projects).toHaveLength(2); + expect(cache.errors).toEqual([]); + expect(cache.projects[0].active_tasks[0].slug).toBe('t1'); + }); + + it('skips 404 (no STATUS.md) without error', async () => { + const cache = await runSync({ + client: fakeClient( + [{ name: 'a', default_branch: 'main' }, { name: 'no-tasks', default_branch: 'main' }], + { a: sample, 'no-tasks': null }, + ), + parseStatus: parseStatusMd, + now, + machine: 'm', + giteaUrl: 'https://g', + user: 'u', + }); + expect(cache.projects.map((p) => p.name)).toEqual(['a']); + expect(cache.errors).toEqual([]); + }); + + it('records error and continues on network failure', async () => { + const cache = await runSync({ + client: fakeClient( + [{ name: 'a', default_branch: 'main' }, { name: 'broken', default_branch: 'main' }], + { a: sample, broken: new Error('network down') }, + ), + parseStatus: parseStatusMd, + now, + machine: 'm', + giteaUrl: 'https://g', + user: 'u', + }); + expect(cache.projects.map((p) => p.name)).toEqual(['a']); + expect(cache.errors).toEqual([{ project: 'broken', reason: 'network down' }]); + }); + + it('sets synced_at, machine, synced_from', async () => { + const cache = await runSync({ + client: fakeClient([], {}), + parseStatus: parseStatusMd, + now, + machine: 'host-x', + giteaUrl: 'https://g', + user: 'u', + }); + expect(cache.synced_at).toBe('2026-04-29T12:00:00.000Z'); + expect(cache.machine).toBe('host-x'); + expect(cache.synced_from).toBe('https://g'); + }); +}); +``` + +- [ ] **Step 2: Run, expect FAIL** + +Run: `npm test -- tests/lib/sync-runner.test.ts` +Expected: FAIL. + +- [ ] **Step 3: Implement src/lib/sync-runner.ts** + +```ts +import type { CacheFile, ProjectStatus, ActiveTask, SyncError } from './cache.js'; +import type { GiteaClient } from './gitea.js'; +import type { ParsedStatus, TaskStatus } from './status-md.js'; + +export interface SyncDeps { + client: GiteaClient; + parseStatus: (raw: string) => ParsedStatus; + now: () => Date; + machine: string; + giteaUrl: string; + user: string; + concurrency?: number; +} + +const ACTIVE_STATES: TaskStatus[] = ['active', 'paused', 'blocked']; + +async function pool(items: T[], n: number, fn: (x: T) => Promise): Promise { + const out = new Array(items.length); + let cursor = 0; + async function worker() { + while (true) { + const i = cursor++; + if (i >= items.length) return; + out[i] = await fn(items[i]); + } + } + await Promise.all(Array.from({ length: Math.min(n, items.length) }, worker)); + return out; +} + +export async function runSync(deps: SyncDeps): Promise { + const concurrency = deps.concurrency ?? 10; + const repos = await deps.client.listUserRepos(deps.user); + const projects: ProjectStatus[] = []; + const errors: SyncError[] = []; + + type Outcome = + | { kind: 'project'; data: ProjectStatus } + | { kind: 'skip' } + | { kind: 'error'; data: SyncError }; + + const results = await pool(repos, concurrency, async (repo) => { + try { + const raw = await deps.client.getRawFile(deps.user, repo.name, '.tasks/STATUS.md', repo.default_branch); + if (raw === null) return { kind: 'skip' as const }; + const parsed = deps.parseStatus(raw); + const active: ActiveTask[] = parsed.tasks + .filter((t) => ACTIVE_STATES.includes(t.status)) + .map((t) => ({ slug: t.slug, status: t.status, next: t.next })); + return { + kind: 'project' as const, + data: { + name: repo.name, + default_branch: repo.default_branch, + fetched_at: deps.now().toISOString(), + active_tasks: active, + all_tasks_count: parsed.tasks.length, + raw, + }, + }; + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + return { kind: 'error' as const, data: { project: repo.name, reason } }; + } + }); + + for (const r of results) { + if (r.kind === 'project') projects.push(r.data); + else if (r.kind === 'error') errors.push(r.data); + } + + return { + synced_at: deps.now().toISOString(), + synced_from: deps.giteaUrl, + machine: deps.machine, + projects, + errors, + }; +} +``` + +- [ ] **Step 4: Run, expect PASS** + +Run: `npm test -- tests/lib/sync-runner.test.ts` +Expected: 4 passed. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/sync-runner.ts tests/lib/sync-runner.test.ts +git commit -m "feat(sync): orchestrator with concurrency, 404-skip, error-collect" +``` + +--- + +### Task 2.6: Sync CLI entry + +**Files:** +- Modify: `src/sync.ts` (replace skeleton) + +- [ ] **Step 1: Implement src/sync.ts** + +```ts +import { homedir, hostname } from 'node:os'; +import { appendFile, mkdir } from 'node:fs/promises'; +import { dirname } from 'node:path'; +import { writeCache } from './lib/cache.js'; +import { getPaths, loadAuth } from './lib/config.js'; +import { makeGiteaClient } from './lib/gitea.js'; +import { parseStatusMd } from './lib/status-md.js'; +import { runSync } from './lib/sync-runner.js'; + +async function main(): Promise { + const paths = getPaths(homedir()); + const auth = await loadAuth(paths.authFile).catch((err) => { + console.error(`auth.toml load failed: ${(err as Error).message}`); + console.error(`Expected at: ${paths.authFile}`); + process.exit(2); + }); + + const client = makeGiteaClient({ baseUrl: auth.giteaUrl, token: auth.giteaToken }); + const cache = await runSync({ + client, + parseStatus: parseStatusMd, + now: () => new Date(), + machine: hostname(), + giteaUrl: auth.giteaUrl, + user: auth.giteaUser, + }); + + await writeCache(paths.cacheFile, cache); + + await mkdir(dirname(paths.syncLog), { recursive: true }); + const line = + `[${cache.synced_at}] projects=${cache.projects.length} errors=${cache.errors.length}` + + (cache.errors.length ? ` (${cache.errors.map((e) => e.project).join(',')})` : '') + + '\n'; + await appendFile(paths.syncLog, line); + + console.log(`synced ${cache.projects.length} projects, ${cache.errors.length} errors`); +} + +await main(); +``` + +- [ ] **Step 2: Build + verify it compiles** + +Run: `npm run build` +Expected: no errors. `dist/sync.js` exists. + +- [ ] **Step 3: Run typecheck** + +Run: `npm run typecheck` +Expected: no errors. + +- [ ] **Step 4: Commit** + +```bash +git add src/sync.ts +git commit -m "feat(sync): CLI entry wiring config + runner + cache + log" +``` + +--- + +## Phase 3 — MCP server core + +### Task 3.1: Server bootstrap (stdio + tool registry) + +**Files:** +- Modify: `src/server.ts` (replace skeleton) + +The MCP SDK exposes `Server` and `StdioServerTransport`. Tools are registered in handlers. The server reads `process.argv[2]` if given as `--cwd ` for explicit cwd-override; otherwise uses `process.cwd()`. + +- [ ] **Step 1: Replace src/server.ts** + +```ts +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, +} from '@modelcontextprotocol/sdk/types.js'; +import { homedir } from 'node:os'; +import { getPaths } from './lib/config.js'; +import { makeTasksTools } from './tools/tasks.js'; +import { makeKnowledgeTools } from './tools/knowledge.js'; +import { makeMetaTools } from './tools/meta.js'; + +function getCwdArg(): string { + const i = process.argv.indexOf('--cwd'); + if (i >= 0 && process.argv[i + 1]) return process.argv[i + 1]; + return process.cwd(); +} + +async function main(): Promise { + const paths = getPaths(homedir()); + const cwd = getCwdArg(); + + const tools = [ + ...makeTasksTools({ cacheFile: paths.cacheFile }), + ...makeKnowledgeTools({ wikiRoot: paths.sharedWikiClone, projectCwd: cwd }), + ...makeMetaTools({ paths }), + ]; + + const byName = new Map(tools.map((t) => [t.name, t])); + + const server = new Server( + { name: 'projects-meta-mcp', version: '0.1.0' }, + { capabilities: { tools: {} } }, + ); + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: tools.map((t) => ({ + name: t.name, + description: t.description, + inputSchema: t.inputSchema, + })), + })); + + server.setRequestHandler(CallToolRequestSchema, async (req) => { + const tool = byName.get(req.params.name); + if (!tool) { + return { + content: [{ type: 'text', text: `Unknown tool: ${req.params.name}` }], + isError: true, + }; + } + return await tool.handler(req.params.arguments ?? {}); + }); + + const transport = new StdioServerTransport(); + await server.connect(transport); +} + +await main(); +``` + +- [ ] **Step 2: Define ToolDef shape** + +Create `src/tools/types.ts`: + +```ts +export interface ToolResult { + content: Array<{ type: 'text'; text: string }>; + isError?: boolean; +} + +export interface ToolDef { + name: string; + description: string; + inputSchema: { + type: 'object'; + properties?: Record; + required?: string[]; + additionalProperties?: boolean; + }; + handler: (args: Record) => Promise; +} +``` + +- [ ] **Step 3: Stub the three tool factories so build passes** + +Create empty stubs (real impl in later tasks). Each returns `[]`. + +`src/tools/tasks.ts`: +```ts +import type { ToolDef } from './types.js'; + +export function makeTasksTools(_opts: { cacheFile: string }): ToolDef[] { + return []; +} +``` + +`src/tools/knowledge.ts`: +```ts +import type { ToolDef } from './types.js'; + +export function makeKnowledgeTools(_opts: { + wikiRoot: string; + projectCwd: string; +}): ToolDef[] { + return []; +} +``` + +`src/tools/meta.ts`: +```ts +import type { ToolDef } from './types.js'; +import type { Paths } from '../lib/config.js'; + +export function makeMetaTools(_opts: { paths: Paths }): ToolDef[] { + return []; +} +``` + +- [ ] **Step 4: Build** + +Run: `npm run build` +Expected: no errors. + +- [ ] **Step 5: Commit** + +```bash +git add src/server.ts src/tools/types.ts src/tools/tasks.ts src/tools/knowledge.ts src/tools/meta.ts +git commit -m "feat(server): stdio bootstrap + tool registry skeleton" +``` + +--- + +### Task 3.2: Tasks tools (`tasks.aggregate`, `tasks.search`, `tasks.get`) + +**Files:** +- Modify: `src/tools/tasks.ts` +- Create: `tests/tools/tasks.test.ts` + +**Contracts:** +- `tasks.aggregate({ status?, project? })` → list of `{ project, slug, status, description, next }` filtered by both. No raw text. +- `tasks.search({ query })` → matches `query` (case-insensitive substring) against `slug + description + next`. Returns same shape as aggregate. +- `tasks.get({ project })` → returns full STATUS.md raw of one project. + +- [ ] **Step 1: Write failing tests** + +`tests/tools/tasks.test.ts`: +```ts +import { describe, it, expect, beforeEach } from 'vitest'; +import { mkdtemp } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { writeCache, type CacheFile } from '../../src/lib/cache.js'; +import { makeTasksTools } from '../../src/tools/tasks.js'; + +const fixture: CacheFile = { + synced_at: '2026-04-29T12:00:00.000Z', + synced_from: 'https://g', + machine: 'm', + projects: [ + { + name: 'alpha', + default_branch: 'main', + fetched_at: '2026-04-29T12:00:01.000Z', + active_tasks: [ + { slug: 'auth-rewrite', status: 'active', next: 'add tests' }, + { slug: 'docs', status: 'paused', next: 'screencast' }, + ], + all_tasks_count: 2, + raw: '# Task Board\n## 🔴 [auth-rewrite] — Auth\n', + }, + { + name: 'beta', + default_branch: 'main', + fetched_at: '2026-04-29T12:00:02.000Z', + active_tasks: [{ slug: 'login', status: 'active', next: 'fix bug' }], + all_tasks_count: 1, + raw: '# Task Board\n## 🔴 [login] — Login\n', + }, + ], + errors: [], +}; + +let cacheFile: string; + +beforeEach(async () => { + const dir = await mkdtemp(join(tmpdir(), 'tasks-tools-')); + cacheFile = join(dir, 'tasks.json'); + await writeCache(cacheFile, fixture); +}); + +function call(tools: ReturnType, name: string, args: Record) { + const t = tools.find((x) => x.name === name); + if (!t) throw new Error(`tool ${name} not registered`); + return t.handler(args); +} + +describe('tasks.aggregate', () => { + it('lists everything by default', async () => { + const tools = makeTasksTools({ cacheFile }); + const r = await call(tools, 'tasks.aggregate', {}); + const parsed = JSON.parse(r.content[0].text); + expect(parsed.tasks).toHaveLength(3); + expect(parsed.tasks[0]).toEqual({ + project: 'alpha', + slug: 'auth-rewrite', + status: 'active', + next: 'add tests', + }); + }); + + it('filters by status', async () => { + const tools = makeTasksTools({ cacheFile }); + const r = await call(tools, 'tasks.aggregate', { status: 'active' }); + const parsed = JSON.parse(r.content[0].text); + expect(parsed.tasks.map((t: { slug: string }) => t.slug)).toEqual(['auth-rewrite', 'login']); + }); + + it('filters by project', async () => { + const tools = makeTasksTools({ cacheFile }); + const r = await call(tools, 'tasks.aggregate', { project: 'beta' }); + const parsed = JSON.parse(r.content[0].text); + expect(parsed.tasks.map((t: { slug: string }) => t.slug)).toEqual(['login']); + }); + + it('returns empty when cache missing', async () => { + const tools = makeTasksTools({ cacheFile: '/nonexistent/tasks.json' }); + const r = await call(tools, 'tasks.aggregate', {}); + const parsed = JSON.parse(r.content[0].text); + expect(parsed.tasks).toEqual([]); + expect(parsed.cache_missing).toBe(true); + }); +}); + +describe('tasks.search', () => { + it('matches case-insensitive substring on slug/next', async () => { + const tools = makeTasksTools({ cacheFile }); + const r = await call(tools, 'tasks.search', { query: 'AUTH' }); + const parsed = JSON.parse(r.content[0].text); + expect(parsed.tasks.map((t: { slug: string }) => t.slug)).toEqual(['auth-rewrite']); + }); + + it('matches on next-action text', async () => { + const tools = makeTasksTools({ cacheFile }); + const r = await call(tools, 'tasks.search', { query: 'screencast' }); + const parsed = JSON.parse(r.content[0].text); + expect(parsed.tasks.map((t: { slug: string }) => t.slug)).toEqual(['docs']); + }); +}); + +describe('tasks.get', () => { + it('returns full raw STATUS.md', async () => { + const tools = makeTasksTools({ cacheFile }); + const r = await call(tools, 'tasks.get', { project: 'alpha' }); + expect(r.content[0].text).toContain('[auth-rewrite]'); + }); + + it('returns error for unknown project', async () => { + const tools = makeTasksTools({ cacheFile }); + const r = await call(tools, 'tasks.get', { project: 'ghost' }); + expect(r.isError).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run, expect FAIL** + +Run: `npm test -- tests/tools/tasks.test.ts` +Expected: FAIL. + +- [ ] **Step 3: Implement src/tools/tasks.ts** + +```ts +import { z } from 'zod'; +import { readCache } from '../lib/cache.js'; +import type { ToolDef, ToolResult } from './types.js'; + +interface Opts { + cacheFile: string; +} + +const StatusEnum = z.enum(['active', 'paused', 'blocked', 'ready', 'done']); + +const AggregateInput = z.object({ + status: StatusEnum.optional(), + project: z.string().optional(), +}); + +const SearchInput = z.object({ + query: z.string().min(1), +}); + +const GetInput = z.object({ + project: z.string().min(1), +}); + +function asJson(value: unknown): ToolResult { + return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] }; +} + +function asError(msg: string): ToolResult { + return { content: [{ type: 'text', text: msg }], isError: true }; +} + +export function makeTasksTools(opts: Opts): ToolDef[] { + return [ + { + name: 'tasks.aggregate', + description: + 'Свод активных задач (active/paused/blocked) по всем проектам. Фильтры по статусу и имени проекта. Не возвращает полный текст STATUS.md.', + inputSchema: { + type: 'object', + properties: { + status: { type: 'string', enum: ['active', 'paused', 'blocked', 'ready', 'done'] }, + project: { type: 'string' }, + }, + additionalProperties: false, + }, + async handler(args) { + const input = AggregateInput.parse(args); + const cache = await readCache(opts.cacheFile); + if (!cache) return asJson({ tasks: [], cache_missing: true }); + + const out: Array<{ project: string; slug: string; status: string; next: string | null }> = []; + for (const p of cache.projects) { + if (input.project && p.name !== input.project) continue; + for (const t of p.active_tasks) { + if (input.status && t.status !== input.status) continue; + out.push({ project: p.name, slug: t.slug, status: t.status, next: t.next }); + } + } + return asJson({ tasks: out, synced_at: cache.synced_at }); + }, + }, + { + name: 'tasks.search', + description: + 'Подстрочный регистронезависимый поиск по slug / next-action всех задач в кэше. Возвращает то же что tasks.aggregate.', + inputSchema: { + type: 'object', + properties: { query: { type: 'string' } }, + required: ['query'], + additionalProperties: false, + }, + async handler(args) { + const input = SearchInput.parse(args); + const cache = await readCache(opts.cacheFile); + if (!cache) return asJson({ tasks: [], cache_missing: true }); + + const q = input.query.toLowerCase(); + const out: Array<{ project: string; slug: string; status: string; next: string | null }> = []; + for (const p of cache.projects) { + for (const t of p.active_tasks) { + const hay = `${t.slug} ${t.next ?? ''}`.toLowerCase(); + if (hay.includes(q)) { + out.push({ project: p.name, slug: t.slug, status: t.status, next: t.next }); + } + } + } + return asJson({ tasks: out, synced_at: cache.synced_at }); + }, + }, + { + name: 'tasks.get', + description: 'Полный raw STATUS.md одного проекта (как было получено при последнем sync).', + inputSchema: { + type: 'object', + properties: { project: { type: 'string' } }, + required: ['project'], + additionalProperties: false, + }, + async handler(args) { + const input = GetInput.parse(args); + const cache = await readCache(opts.cacheFile); + if (!cache) return asError('cache missing — run sync first'); + const p = cache.projects.find((x) => x.name === input.project); + if (!p) return asError(`project not found: ${input.project}`); + return { content: [{ type: 'text', text: p.raw }] }; + }, + }, + ]; +} +``` + +- [ ] **Step 4: Run, expect PASS** + +Run: `npm test -- tests/tools/tasks.test.ts` +Expected: 8 passed. + +- [ ] **Step 5: Commit** + +```bash +git add src/tools/tasks.ts tests/tools/tasks.test.ts +git commit -m "feat(tools): tasks.aggregate/search/get" +``` + +--- + +### Task 3.3: Domain detector + +**Files:** +- Create: `src/lib/domain-detector.ts` +- Create: `tests/lib/domain-detector.test.ts` + +**Contract:** +```ts +export type Domain = 'node' | 'embedded' | 'web' | 'unknown'; +export async function detectDomain(cwd: string): Promise; +``` + +Decision rules (spec §detect domain): +1. `package.json` exists → `node` +2. `platformio.ini` OR any `*.ino` OR `CMakeLists.txt` containing `arm-none-eabi` or `STM32` or `ESP_IDF` → `embedded` +3. `next.config.*` OR `vite.config.*` OR (`index.html` AND no `package.json`) → `web` +4. else → `unknown` + +- [ ] **Step 1: Write failing tests** + +`tests/lib/domain-detector.test.ts`: +```ts +import { describe, it, expect } from 'vitest'; +import { mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { detectDomain } from '../../src/lib/domain-detector.js'; + +async function tmp(): Promise { + return mkdtemp(join(tmpdir(), 'dd-')); +} + +describe('detectDomain', () => { + it('returns node when package.json present', async () => { + const d = await tmp(); + await writeFile(join(d, 'package.json'), '{}'); + expect(await detectDomain(d)).toBe('node'); + }); + + it('returns embedded for platformio.ini', async () => { + const d = await tmp(); + await writeFile(join(d, 'platformio.ini'), '[env]'); + expect(await detectDomain(d)).toBe('embedded'); + }); + + it('returns embedded for .ino', async () => { + const d = await tmp(); + await writeFile(join(d, 'sketch.ino'), 'void setup() {}'); + expect(await detectDomain(d)).toBe('embedded'); + }); + + it('returns embedded for ARM CMakeLists', async () => { + const d = await tmp(); + await writeFile(join(d, 'CMakeLists.txt'), 'set(CMAKE_C_COMPILER arm-none-eabi-gcc)'); + expect(await detectDomain(d)).toBe('embedded'); + }); + + it('returns web for vite config', async () => { + const d = await tmp(); + await writeFile(join(d, 'vite.config.ts'), 'export default {}'); + expect(await detectDomain(d)).toBe('web'); + }); + + it('returns web for static index.html without package.json', async () => { + const d = await tmp(); + await writeFile(join(d, 'index.html'), ''); + expect(await detectDomain(d)).toBe('web'); + }); + + it('returns unknown for empty dir', async () => { + const d = await tmp(); + expect(await detectDomain(d)).toBe('unknown'); + }); + + it('package.json wins over index.html', async () => { + const d = await tmp(); + await writeFile(join(d, 'package.json'), '{}'); + await writeFile(join(d, 'index.html'), ''); + expect(await detectDomain(d)).toBe('node'); + }); +}); +``` + +- [ ] **Step 2: Run, expect FAIL** + +Run: `npm test -- tests/lib/domain-detector.test.ts` +Expected: FAIL. + +- [ ] **Step 3: Implement src/lib/domain-detector.ts** + +```ts +import { readdir, readFile, stat } from 'node:fs/promises'; +import { join } from 'node:path'; + +export type Domain = 'node' | 'embedded' | 'web' | 'unknown'; + +const EMBEDDED_NEEDLES = ['arm-none-eabi', 'STM32', 'ESP_IDF', 'STM32CubeMX']; + +async function exists(p: string): Promise { + try { + await stat(p); + return true; + } catch { + return false; + } +} + +async function listFiles(dir: string): Promise { + try { + const entries = await readdir(dir, { withFileTypes: true }); + return entries.filter((e) => e.isFile()).map((e) => e.name); + } catch { + return []; + } +} + +export async function detectDomain(cwd: string): Promise { + const pkgJson = await exists(join(cwd, 'package.json')); + if (pkgJson) return 'node'; + + if (await exists(join(cwd, 'platformio.ini'))) return 'embedded'; + + const files = await listFiles(cwd); + if (files.some((f) => f.endsWith('.ino'))) return 'embedded'; + + if (files.includes('CMakeLists.txt')) { + const txt = await readFile(join(cwd, 'CMakeLists.txt'), 'utf8').catch(() => ''); + if (EMBEDDED_NEEDLES.some((n) => txt.includes(n))) return 'embedded'; + } + + if (files.some((f) => /^next\.config\.(js|mjs|cjs|ts)$/.test(f))) return 'web'; + if (files.some((f) => /^vite\.config\.(js|mjs|cjs|ts)$/.test(f))) return 'web'; + if (files.includes('index.html')) return 'web'; + + return 'unknown'; +} +``` + +- [ ] **Step 4: Run, expect PASS** + +Run: `npm test -- tests/lib/domain-detector.test.ts` +Expected: 8 passed. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/domain-detector.ts tests/lib/domain-detector.test.ts +git commit -m "feat(domain): node/embedded/web detector by cwd files" +``` + +--- + +### Task 3.4: Knowledge tools (`knowledge.search`, `knowledge.get`) + +**Files:** +- Create: `src/lib/wiki-index.ts` +- Modify: `src/tools/knowledge.ts` +- Create: `tests/lib/wiki-index.test.ts` +- Create: `tests/tools/knowledge.test.ts` + +**Contract for wiki-index:** +```ts +export interface WikiPage { + slug: string; // path relative to wikiRoot, no .md extension, e.g. "node/windows-yarn" + title: string; + domain: string; // from frontmatter + tags: string[]; + body: string; // body text only (no frontmatter) + raw: string; // full file contents +} + +export async function loadWiki(root: string): Promise; +``` + +Walk all `*.md` files except `CLAUDE.md` and `README.md` at root level (those are wiki meta). Skip pages without `domain:` frontmatter (warn but include with `domain="unknown"`). + +**Contract for knowledge tools:** +- `knowledge.search({ query, domain?, limit? })` → list of `{ slug, title, snippet, domain }`. Default `limit=10`. Default domain filter = detected from cwd; if cwd domain is `unknown` no filter; if `domain="all"` no filter; otherwise show pages where `domain == filter || domain == "cross"`. +- `knowledge.get({ slug })` → full page contents. + +- [ ] **Step 1: Write failing wiki-index tests** + +`tests/lib/wiki-index.test.ts`: +```ts +import { describe, it, expect, beforeEach } from 'vitest'; +import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { loadWiki } from '../../src/lib/wiki-index.js'; + +let root: string; + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'wiki-')); + await mkdir(join(root, 'node'), { recursive: true }); + await mkdir(join(root, 'cross'), { recursive: true }); + + await writeFile(join(root, 'CLAUDE.md'), 'should be skipped'); + await writeFile(join(root, 'README.md'), 'should be skipped'); + + await writeFile( + join(root, 'node', 'windows-yarn-exec.md'), + [ + '---', + 'title: Windows yarn requires exec()', + 'domain: node', + 'tags: [windows, yarn]', + '---', + '', + '## Why', + 'Windows is special.', + ].join('\n'), + ); + + await writeFile( + join(root, 'cross', 'git-tips.md'), + ['---', 'title: Git tips', 'domain: cross', 'tags: [git]', '---', '', 'body'].join('\n'), + ); + + await writeFile( + join(root, 'cross', 'no-fm.md'), + 'just a body, no frontmatter', + ); +}); + +describe('loadWiki', () => { + it('skips top-level meta files', async () => { + const pages = await loadWiki(root); + expect(pages.find((p) => p.slug === 'CLAUDE')).toBeUndefined(); + expect(pages.find((p) => p.slug === 'README')).toBeUndefined(); + }); + + it('loads pages with frontmatter', async () => { + const pages = await loadWiki(root); + const yarn = pages.find((p) => p.slug === 'node/windows-yarn-exec'); + expect(yarn).toBeDefined(); + expect(yarn!.title).toBe('Windows yarn requires exec()'); + expect(yarn!.domain).toBe('node'); + expect(yarn!.tags).toEqual(['windows', 'yarn']); + expect(yarn!.body).toContain('Windows is special.'); + }); + + it('marks frontmatterless pages as unknown domain', async () => { + const pages = await loadWiki(root); + const fm = pages.find((p) => p.slug === 'cross/no-fm'); + expect(fm).toBeDefined(); + expect(fm!.domain).toBe('unknown'); + }); + + it('returns empty array if root missing', async () => { + const pages = await loadWiki(join(root, 'does-not-exist')); + expect(pages).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Run, expect FAIL** + +Run: `npm test -- tests/lib/wiki-index.test.ts` +Expected: FAIL. + +- [ ] **Step 3: Implement src/lib/wiki-index.ts** + +```ts +import { readdir, readFile, stat } from 'node:fs/promises'; +import { join, relative, sep } from 'node:path'; +import matter from 'gray-matter'; + +export interface WikiPage { + slug: string; + title: string; + domain: string; + tags: string[]; + body: string; + raw: string; +} + +const META_TOP = new Set(['CLAUDE.md', 'README.md', 'index.md', 'log.md', 'overview.md']); + +async function walk(root: string, prefix = ''): Promise { + const out: string[] = []; + let entries; + try { + entries = await readdir(root, { withFileTypes: true }); + } catch { + return []; + } + for (const e of entries) { + const rel = prefix ? `${prefix}/${e.name}` : e.name; + if (e.isDirectory()) { + if (e.name.startsWith('.')) continue; + const nested = await walk(join(root, e.name), rel); + out.push(...nested); + } else if (e.isFile() && e.name.endsWith('.md')) { + if (prefix === '' && META_TOP.has(e.name)) continue; + out.push(rel); + } + } + return out; +} + +export async function loadWiki(root: string): Promise { + try { + const s = await stat(root); + if (!s.isDirectory()) return []; + } catch { + return []; + } + + const relPaths = await walk(root); + const pages: WikiPage[] = []; + + for (const rel of relPaths) { + const abs = join(root, ...rel.split('/')); + const raw = await readFile(abs, 'utf8'); + const slug = rel.replace(/\.md$/, '').split(sep).join('/'); + const parsed = matter(raw); + const fm = parsed.data as Record; + + pages.push({ + slug, + title: typeof fm.title === 'string' ? fm.title : slug, + domain: typeof fm.domain === 'string' ? fm.domain : 'unknown', + tags: Array.isArray(fm.tags) ? (fm.tags as unknown[]).filter((t): t is string => typeof t === 'string') : [], + body: parsed.content, + raw, + }); + } + + return pages; +} +``` + +- [ ] **Step 4: Run wiki-index tests, expect PASS** + +Run: `npm test -- tests/lib/wiki-index.test.ts` +Expected: 4 passed. + +- [ ] **Step 5: Commit wiki-index** + +```bash +git add src/lib/wiki-index.ts tests/lib/wiki-index.test.ts +git commit -m "feat(wiki): page loader with frontmatter parse" +``` + +- [ ] **Step 6: Write failing knowledge-tools tests** + +`tests/tools/knowledge.test.ts`: +```ts +import { describe, it, expect, beforeEach } from 'vitest'; +import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { makeKnowledgeTools } from '../../src/tools/knowledge.js'; + +let wikiRoot: string; +let nodeProject: string; +let unknownProject: string; + +beforeEach(async () => { + wikiRoot = await mkdtemp(join(tmpdir(), 'kw-wiki-')); + await mkdir(join(wikiRoot, 'node'), { recursive: true }); + await mkdir(join(wikiRoot, 'embedded'), { recursive: true }); + await mkdir(join(wikiRoot, 'cross'), { recursive: true }); + + await writeFile( + join(wikiRoot, 'node', 'yarn-windows.md'), + '---\ntitle: Yarn on Windows\ndomain: node\n---\n\nyarn body', + ); + await writeFile( + join(wikiRoot, 'embedded', 'stm32-flash.md'), + '---\ntitle: STM32 Flashing\ndomain: embedded\n---\n\nstm32 body', + ); + await writeFile( + join(wikiRoot, 'cross', 'git-trick.md'), + '---\ntitle: Git Trick\ndomain: cross\n---\n\ngit yarn body', + ); + + nodeProject = await mkdtemp(join(tmpdir(), 'kw-proj-')); + await writeFile(join(nodeProject, 'package.json'), '{}'); + + unknownProject = await mkdtemp(join(tmpdir(), 'kw-proj-')); +}); + +function call(tools: ReturnType, name: string, args: Record) { + const t = tools.find((x) => x.name === name); + if (!t) throw new Error(`tool ${name} missing`); + return t.handler(args); +} + +describe('knowledge.search (node project)', () => { + it('shows node + cross by default, hides embedded', async () => { + const tools = makeKnowledgeTools({ wikiRoot, projectCwd: nodeProject }); + const r = await call(tools, 'knowledge.search', { query: 'yarn' }); + const parsed = JSON.parse(r.content[0].text); + const slugs = parsed.results.map((x: { slug: string }) => x.slug).sort(); + expect(slugs).toEqual(['cross/git-trick', 'node/yarn-windows']); + }); + + it('domain="all" shows everything', async () => { + const tools = makeKnowledgeTools({ wikiRoot, projectCwd: nodeProject }); + const r = await call(tools, 'knowledge.search', { query: '', domain: 'all' }); + const parsed = JSON.parse(r.content[0].text); + expect(parsed.results.length).toBe(3); + }); + + it('explicit domain switches filter', async () => { + const tools = makeKnowledgeTools({ wikiRoot, projectCwd: nodeProject }); + const r = await call(tools, 'knowledge.search', { query: '', domain: 'embedded' }); + const parsed = JSON.parse(r.content[0].text); + const slugs = parsed.results.map((x: { slug: string }) => x.slug).sort(); + expect(slugs).toEqual(['cross/git-trick', 'embedded/stm32-flash']); + }); +}); + +describe('knowledge.search (unknown project)', () => { + it('returns all when cwd domain unknown', async () => { + const tools = makeKnowledgeTools({ wikiRoot, projectCwd: unknownProject }); + const r = await call(tools, 'knowledge.search', { query: '' }); + const parsed = JSON.parse(r.content[0].text); + expect(parsed.results.length).toBe(3); + }); +}); + +describe('knowledge.get', () => { + it('returns full page including frontmatter', async () => { + const tools = makeKnowledgeTools({ wikiRoot, projectCwd: nodeProject }); + const r = await call(tools, 'knowledge.get', { slug: 'node/yarn-windows' }); + expect(r.content[0].text).toContain('domain: node'); + expect(r.content[0].text).toContain('yarn body'); + }); + + it('errors on missing slug', async () => { + const tools = makeKnowledgeTools({ wikiRoot, projectCwd: nodeProject }); + const r = await call(tools, 'knowledge.get', { slug: 'ghost/page' }); + expect(r.isError).toBe(true); + }); +}); +``` + +- [ ] **Step 7: Run, expect FAIL** + +Run: `npm test -- tests/tools/knowledge.test.ts` +Expected: FAIL. + +- [ ] **Step 8: Implement src/tools/knowledge.ts** + +```ts +import { z } from 'zod'; +import { detectDomain, type Domain } from '../lib/domain-detector.js'; +import { loadWiki, type WikiPage } from '../lib/wiki-index.js'; +import type { ToolDef, ToolResult } from './types.js'; + +interface Opts { + wikiRoot: string; + projectCwd: string; +} + +const SearchInput = z.object({ + query: z.string(), + domain: z.string().optional(), + limit: z.number().int().positive().max(50).optional(), +}); + +const GetInput = z.object({ + slug: z.string().min(1), +}); + +function asJson(value: unknown): ToolResult { + return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] }; +} + +function asError(msg: string): ToolResult { + return { content: [{ type: 'text', text: msg }], isError: true }; +} + +function snippet(body: string, query: string, span = 80): string { + const trimmed = body.replace(/\s+/g, ' ').trim(); + if (!query) return trimmed.slice(0, span); + const idx = trimmed.toLowerCase().indexOf(query.toLowerCase()); + if (idx < 0) return trimmed.slice(0, span); + const start = Math.max(0, idx - 20); + return trimmed.slice(start, start + span); +} + +function pageMatches(p: WikiPage, query: string): boolean { + if (!query) return true; + const q = query.toLowerCase(); + return ( + p.title.toLowerCase().includes(q) || + p.body.toLowerCase().includes(q) || + p.tags.some((t) => t.toLowerCase().includes(q)) + ); +} + +function domainAllows(pageDomain: string, filter: string): boolean { + if (filter === 'all') return true; + return pageDomain === filter || pageDomain === 'cross'; +} + +export function makeKnowledgeTools(opts: Opts): ToolDef[] { + let cached: WikiPage[] | null = null; + async function pages(): Promise { + if (cached) return cached; + cached = await loadWiki(opts.wikiRoot); + return cached; + } + + let detectedDomain: Domain | null = null; + async function domain(): Promise { + if (detectedDomain) return detectedDomain; + detectedDomain = await detectDomain(opts.projectCwd); + return detectedDomain; + } + + return [ + { + name: 'knowledge.search', + description: + 'Поиск по shared-вики (репо projects-wiki). По умолчанию фильтр по domain соответствует cwd текущего проекта (node/embedded/web). Передай domain="all" чтобы снять фильтр или явное имя домена. limit по умолчанию 10. Возвращает только заголовок + сниппет; полный текст — knowledge.get.', + inputSchema: { + type: 'object', + properties: { + query: { type: 'string' }, + domain: { type: 'string' }, + limit: { type: 'integer', minimum: 1, maximum: 50 }, + }, + required: ['query'], + additionalProperties: false, + }, + async handler(args) { + const input = SearchInput.parse(args); + const all = await pages(); + const det = await domain(); + const filter = input.domain ?? (det === 'unknown' ? 'all' : det); + const limit = input.limit ?? 10; + + const matched = all + .filter((p) => domainAllows(p.domain, filter)) + .filter((p) => pageMatches(p, input.query)) + .slice(0, limit) + .map((p) => ({ + slug: p.slug, + title: p.title, + domain: p.domain, + snippet: snippet(p.body, input.query), + })); + + return asJson({ + detected_domain: det, + applied_filter: filter, + results: matched, + }); + }, + }, + { + name: 'knowledge.get', + description: 'Полный текст одной страницы projects-wiki по её slug (например "node/windows-yarn-exec").', + inputSchema: { + type: 'object', + properties: { slug: { type: 'string' } }, + required: ['slug'], + additionalProperties: false, + }, + async handler(args) { + const input = GetInput.parse(args); + const all = await pages(); + const page = all.find((p) => p.slug === input.slug); + if (!page) return asError(`page not found: ${input.slug}`); + return { content: [{ type: 'text', text: page.raw }] }; + }, + }, + ]; +} +``` + +- [ ] **Step 9: Run, expect PASS** + +Run: `npm test -- tests/tools/knowledge.test.ts` +Expected: 6 passed. + +- [ ] **Step 10: Commit** + +```bash +git add src/tools/knowledge.ts tests/tools/knowledge.test.ts +git commit -m "feat(tools): knowledge.search/get with domain filter" +``` + +--- + +### Task 3.5: Meta tools (`meta.status`) + +**Files:** +- Modify: `src/tools/meta.ts` +- Create: `tests/tools/meta.test.ts` + +**Contract:** +``` +meta.status() → { synced_at, age_seconds, projects_count, errors_count, cache_path, wiki_root, wiki_pages_count, stale: boolean } +``` +Stale threshold: 24 hours (`age_seconds > 86400`). + +- [ ] **Step 1: Write failing tests** + +`tests/tools/meta.test.ts`: +```ts +import { describe, it, expect, beforeEach } from 'vitest'; +import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { writeCache } from '../../src/lib/cache.js'; +import { makeMetaTools } from '../../src/tools/meta.js'; +import type { Paths } from '../../src/lib/config.js'; + +function mkPaths(home: string): Paths { + const cacheDir = join(home, '.cache'); + return { + cacheDir, + cacheFile: join(cacheDir, 'tasks.json'), + syncLog: join(cacheDir, 'sync.log'), + authFile: join(home, '.config', 'auth.toml'), + sharedWikiClone: join(home, 'wiki'), + }; +} + +describe('meta.status', () => { + let paths: Paths; + + beforeEach(async () => { + const home = await mkdtemp(join(tmpdir(), 'meta-')); + paths = mkPaths(home); + await mkdir(paths.sharedWikiClone, { recursive: true }); + await mkdir(join(paths.sharedWikiClone, 'node'), { recursive: true }); + await writeFile( + join(paths.sharedWikiClone, 'node', 'one.md'), + '---\ntitle: t\ndomain: node\n---\n\nbody', + ); + }); + + it('reports cache_missing when no cache', async () => { + const tools = makeMetaTools({ paths }); + const r = await (tools.find((t) => t.name === 'meta.status')!.handler({})); + const parsed = JSON.parse(r.content[0].text); + expect(parsed.cache_missing).toBe(true); + expect(parsed.wiki_pages_count).toBe(1); + }); + + it('reports age and stale=false for fresh cache', async () => { + await writeCache(paths.cacheFile, { + synced_at: new Date().toISOString(), + synced_from: 'https://g', + machine: 'm', + projects: [], + errors: [], + }); + const tools = makeMetaTools({ paths }); + const r = await (tools.find((t) => t.name === 'meta.status')!.handler({})); + const parsed = JSON.parse(r.content[0].text); + expect(parsed.stale).toBe(false); + expect(parsed.age_seconds).toBeGreaterThanOrEqual(0); + }); + + it('reports stale=true for >24h cache', async () => { + const old = new Date(Date.now() - 25 * 3600 * 1000).toISOString(); + await writeCache(paths.cacheFile, { + synced_at: old, + synced_from: 'https://g', + machine: 'm', + projects: [{ name: 'p', default_branch: 'main', fetched_at: old, active_tasks: [], all_tasks_count: 0, raw: '' }], + errors: [{ project: 'q', reason: 'x' }], + }); + const tools = makeMetaTools({ paths }); + const r = await (tools.find((t) => t.name === 'meta.status')!.handler({})); + const parsed = JSON.parse(r.content[0].text); + expect(parsed.stale).toBe(true); + expect(parsed.projects_count).toBe(1); + expect(parsed.errors_count).toBe(1); + }); +}); +``` + +- [ ] **Step 2: Run, expect FAIL** + +Run: `npm test -- tests/tools/meta.test.ts` +Expected: FAIL. + +- [ ] **Step 3: Implement src/tools/meta.ts** + +```ts +import { readCache } from '../lib/cache.js'; +import type { Paths } from '../lib/config.js'; +import { loadWiki } from '../lib/wiki-index.js'; +import type { ToolDef, ToolResult } from './types.js'; + +const STALE_AFTER_SEC = 24 * 3600; + +interface Opts { + paths: Paths; +} + +function asJson(value: unknown): ToolResult { + return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] }; +} + +export function makeMetaTools(opts: Opts): ToolDef[] { + return [ + { + name: 'meta.status', + description: + 'Диагностика: возраст кэша, количество проектов / ошибок / страниц wiki. Используй когда нужно понять, свежие ли данные.', + inputSchema: { type: 'object', properties: {}, additionalProperties: false }, + async handler() { + const cache = await readCache(opts.paths.cacheFile); + const wiki = await loadWiki(opts.paths.sharedWikiClone); + if (!cache) { + return asJson({ + cache_missing: true, + cache_path: opts.paths.cacheFile, + wiki_root: opts.paths.sharedWikiClone, + wiki_pages_count: wiki.length, + }); + } + const ageMs = Date.now() - new Date(cache.synced_at).getTime(); + const ageSec = Math.floor(ageMs / 1000); + return asJson({ + synced_at: cache.synced_at, + age_seconds: ageSec, + stale: ageSec > STALE_AFTER_SEC, + projects_count: cache.projects.length, + errors_count: cache.errors.length, + cache_path: opts.paths.cacheFile, + wiki_root: opts.paths.sharedWikiClone, + wiki_pages_count: wiki.length, + }); + }, + }, + ]; +} +``` + +- [ ] **Step 4: Run, expect PASS** + +Run: `npm test -- tests/tools/meta.test.ts` +Expected: 3 passed. + +- [ ] **Step 5: Commit** + +```bash +git add src/tools/meta.ts tests/tools/meta.test.ts +git commit -m "feat(tools): meta.status with stale threshold" +``` + +--- + +### Task 3.6: Promotion candidate tool (`knowledge.suggest_promote`) + +**Files:** +- Create: `src/lib/promotion.ts` +- Create: `tests/lib/promotion.test.ts` +- Modify: `src/tools/knowledge.ts` (add tool) +- Modify: `tests/tools/knowledge.test.ts` (extend tests) + +**Heuristic** (per spec §knowledge.suggest_promote): +- INCLUDE if page mentions any platform/tooling keyword: `Windows`, `Linux`, `macOS`, `yarn`, `npm`, `Node`, `MCP`, `git`, `Docker`, `tsx`, `vite`, `webpack`, `pnpm`. +- EXCLUDE if page mentions any project name from a configured blacklist (passed in opts). +- BOOST confidence if `projects-wiki` already has a page with similar title in any domain (jaccard ≥ 0.4 on tokenized title). +- Returned shape per candidate: `{ slug, current_path, suggested_domain, confidence, reason }`. `suggested_domain` derived from cwd domain (or `cross` if unknown). + +**Contract:** +```ts +export interface PromotionCandidate { + slug: string; + current_path: string; + suggested_domain: string; + confidence: 'low' | 'medium' | 'high'; + reason: string; +} + +export interface PromotionDeps { + projectWikiDir: string; // /.wiki + sharedPages: WikiPage[]; // already loaded + cwdDomain: Domain; + blacklist: string[]; // project / domain names +} + +export async function findCandidates(deps: PromotionDeps): Promise; +``` + +- [ ] **Step 1: Write failing promotion tests** + +`tests/lib/promotion.test.ts`: +```ts +import { describe, it, expect, beforeEach } from 'vitest'; +import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { findCandidates } from '../../src/lib/promotion.js'; +import type { WikiPage } from '../../src/lib/wiki-index.js'; + +let projectWiki: string; + +beforeEach(async () => { + const root = await mkdtemp(join(tmpdir(), 'promo-')); + projectWiki = join(root, '.wiki'); + await mkdir(join(projectWiki, 'concepts'), { recursive: true }); +}); + +const sharedPages: WikiPage[] = [ + { slug: 'embedded/yarn-on-windows', title: 'Yarn on Windows', domain: 'embedded', tags: [], body: '', raw: '' }, +]; + +describe('findCandidates', () => { + it('includes platform-flavored concept', async () => { + await writeFile( + join(projectWiki, 'concepts', 'windows-yarn-quirk.md'), + '---\ntitle: Windows yarn quirk\n---\n\nWhen yarn on Windows, exec() is required.', + ); + const r = await findCandidates({ + projectWikiDir: projectWiki, + sharedPages, + cwdDomain: 'node', + blacklist: [], + }); + expect(r).toHaveLength(1); + expect(r[0].suggested_domain).toBe('node'); + expect(r[0].confidence).toBe('high'); // platform keyword + similar shared title + }); + + it('excludes when project name in blacklist matched', async () => { + await writeFile( + join(projectWiki, 'concepts', 'snolla-edge-case.md'), + '---\ntitle: Snolla edge case\n---\n\nWhen yarn meets the Snolla pipeline, special handling for npm-mcp.', + ); + const r = await findCandidates({ + projectWikiDir: projectWiki, + sharedPages, + cwdDomain: 'node', + blacklist: ['snolla', 'npm-mcp'], + }); + expect(r).toHaveLength(0); + }); + + it('returns confidence=medium when keyword match but no similar shared title', async () => { + await writeFile( + join(projectWiki, 'concepts', 'docker-on-linux.md'), + '---\ntitle: Docker on Linux\n---\n\nDocker daemon socket on Linux.', + ); + const r = await findCandidates({ + projectWikiDir: projectWiki, + sharedPages, + cwdDomain: 'node', + blacklist: [], + }); + expect(r).toHaveLength(1); + expect(r[0].confidence).toBe('medium'); + }); + + it('skips concept without platform keyword', async () => { + await writeFile( + join(projectWiki, 'concepts', 'business-rule.md'), + '---\ntitle: Order discount logic\n---\n\nWhen total > 100 apply 5% off.', + ); + const r = await findCandidates({ + projectWikiDir: projectWiki, + sharedPages, + cwdDomain: 'node', + blacklist: [], + }); + expect(r).toHaveLength(0); + }); + + it('cwdDomain unknown → suggested_domain=cross', async () => { + await writeFile( + join(projectWiki, 'concepts', 'git-trick.md'), + '---\ntitle: Git tip\n---\n\ngit reflog tip.', + ); + const r = await findCandidates({ + projectWikiDir: projectWiki, + sharedPages, + cwdDomain: 'unknown', + blacklist: [], + }); + expect(r[0].suggested_domain).toBe('cross'); + }); +}); +``` + +- [ ] **Step 2: Run, expect FAIL** + +Run: `npm test -- tests/lib/promotion.test.ts` +Expected: FAIL. + +- [ ] **Step 3: Implement src/lib/promotion.ts** + +```ts +import { readdir, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import matter from 'gray-matter'; +import type { Domain } from './domain-detector.js'; +import type { WikiPage } from './wiki-index.js'; + +export interface PromotionCandidate { + slug: string; + current_path: string; + suggested_domain: string; + confidence: 'low' | 'medium' | 'high'; + reason: string; +} + +export interface PromotionDeps { + projectWikiDir: string; + sharedPages: WikiPage[]; + cwdDomain: Domain; + blacklist: string[]; +} + +const PLATFORM_KEYWORDS = [ + 'windows', + 'linux', + 'macos', + 'mac os', + 'yarn', + 'npm', + 'pnpm', + 'node', + 'mcp', + 'git ', + 'docker', + 'tsx', + 'vite', + 'webpack', + 'eslint', + 'tsc', + 'powershell', + 'bash', +]; + +function tokenize(s: string): Set { + return new Set( + s + .toLowerCase() + .replace(/[^a-z0-9 ]+/g, ' ') + .split(/\s+/) + .filter((w) => w.length >= 3), + ); +} + +function jaccard(a: Set, b: Set): number { + if (a.size === 0 && b.size === 0) return 0; + let inter = 0; + for (const x of a) if (b.has(x)) inter += 1; + const union = a.size + b.size - inter; + return union === 0 ? 0 : inter / union; +} + +async function listConcepts(dir: string): Promise { + try { + const files = await readdir(dir, { withFileTypes: true }); + return files.filter((f) => f.isFile() && f.name.endsWith('.md')).map((f) => f.name); + } catch { + return []; + } +} + +function suggestDomain(d: Domain): string { + return d === 'unknown' ? 'cross' : d; +} + +export async function findCandidates(deps: PromotionDeps): Promise { + const conceptsDir = join(deps.projectWikiDir, 'concepts'); + const files = await listConcepts(conceptsDir); + const out: PromotionCandidate[] = []; + + for (const f of files) { + const path = join(conceptsDir, f); + const text = await readFile(path, 'utf8'); + const { data: fm, content } = matter(text); + const title = typeof fm.title === 'string' ? fm.title : f.replace(/\.md$/, ''); + const haystack = `${title}\n${content}`.toLowerCase(); + + if (deps.blacklist.some((b) => haystack.includes(b.toLowerCase()))) continue; + + const matchedKw = PLATFORM_KEYWORDS.find((k) => haystack.includes(k)); + if (!matchedKw) continue; + + const titleTokens = tokenize(title); + const similar = deps.sharedPages.find((p) => jaccard(titleTokens, tokenize(p.title)) >= 0.4); + + const confidence: PromotionCandidate['confidence'] = similar ? 'high' : 'medium'; + const reasonParts = [`keyword: ${matchedKw.trim()}`]; + if (similar) reasonParts.push(`similar shared page: ${similar.slug}`); + + out.push({ + slug: f.replace(/\.md$/, ''), + current_path: path, + suggested_domain: suggestDomain(deps.cwdDomain), + confidence, + reason: reasonParts.join('; '), + }); + } + return out; +} +``` + +- [ ] **Step 4: Run promotion tests, expect PASS** + +Run: `npm test -- tests/lib/promotion.test.ts` +Expected: 5 passed. + +- [ ] **Step 5: Add knowledge.suggest_promote to src/tools/knowledge.ts** + +Insert this tool after `knowledge.get` in the returned array of `makeKnowledgeTools`: + +```ts +{ + name: 'knowledge.suggest_promote', + description: + 'Возвращает кандидатов из текущего .wiki/concepts/ на промоушен в projects-wiki. Фильтр по платформенным ключам, исключение по чёрному списку проектов. Решение всегда у пользователя — этот tool не пишет файлы.', + inputSchema: { type: 'object', properties: {}, additionalProperties: false }, + async handler() { + const det = await domain(); + const shared = await pages(); + const candidates = await findCandidates({ + projectWikiDir: join(opts.projectCwd, '.wiki'), + sharedPages: shared, + cwdDomain: det, + blacklist: [], + }); + return asJson({ candidates }); + }, +}, +``` + +Add imports at top of `src/tools/knowledge.ts`: +```ts +import { join } from 'node:path'; +import { findCandidates } from '../lib/promotion.js'; +``` + +- [ ] **Step 6: Extend tests/tools/knowledge.test.ts with one suggest_promote test** + +Append to the file: + +```ts +import { mkdir as _mkdirFs } from 'node:fs/promises'; + +describe('knowledge.suggest_promote', () => { + it('returns candidates from project .wiki/concepts', async () => { + await _mkdirFs(join(nodeProject, '.wiki', 'concepts'), { recursive: true }); + await writeFile( + join(nodeProject, '.wiki', 'concepts', 'docker-quirk.md'), + '---\ntitle: Docker quirk\n---\n\nDocker daemon stuff.', + ); + const tools = makeKnowledgeTools({ wikiRoot, projectCwd: nodeProject }); + const r = await call(tools, 'knowledge.suggest_promote', {}); + const parsed = JSON.parse(r.content[0].text); + expect(parsed.candidates).toHaveLength(1); + expect(parsed.candidates[0].slug).toBe('docker-quirk'); + expect(parsed.candidates[0].suggested_domain).toBe('node'); + }); +}); +``` + +- [ ] **Step 7: Run all knowledge tests** + +Run: `npm test -- tests/tools/knowledge.test.ts tests/lib/promotion.test.ts` +Expected: all pass. + +- [ ] **Step 8: Commit** + +```bash +git add src/lib/promotion.ts tests/lib/promotion.test.ts src/tools/knowledge.ts tests/tools/knowledge.test.ts +git commit -m "feat(promotion): suggest_promote tool with keyword + similarity heuristic" +``` + +--- + +## Phase 4 — Wiring & smoke + +### Task 4.1: Build + run all tests + +- [ ] **Step 1: Full typecheck + build** + +Run: `npm run typecheck && npm run build` +Expected: zero errors. `dist/sync.js` and `dist/server.js` produced. + +- [ ] **Step 2: Full test suite** + +Run: `npm test` +Expected: every test from previous tasks PASS, no failures. + +- [ ] **Step 3: Smoke-run sync without auth (expect graceful failure)** + +Run: `node dist/sync.js` +Expected: exit code 2 with message `auth.toml load failed: ...` (auth.toml absent on CI/dev box). + +This is the desired graceful behavior; do not commit a fix. + +- [ ] **Step 4: Smoke-run server with stub stdin** + +Run (Windows PowerShell): +```powershell +echo '' | node dist/server.js +``` + +Expected: process stays alive a moment, exits cleanly when stdin closes. No stack traces. + +- [ ] **Step 5: Commit any incidental fixes (probably none)** + +If fixes were needed, commit them now. Otherwise skip. + +--- + +### Task 4.2: Update README + record tasks + +**Files:** +- Modify: `README.md` +- Modify: `.tasks/STATUS.md` +- Create: `.tasks/bootstrap-implementation.md` +- Modify: `.wiki/index.md` +- Modify: `.wiki/log.md` +- Create: `.wiki/entities/sync-script.md` +- Create: `.wiki/entities/mcp-server.md` +- Create: `.wiki/entities/domain-detector.md` +- Create: `.wiki/packages/modelcontextprotocol-sdk.md` +- Create: `.wiki/packages/gray-matter.md` +- Create: `.wiki/packages/smol-toml.md` +- Create: `.wiki/packages/zod.md` + +- [ ] **Step 1: Replace README.md** + +```markdown +# projects-meta-mcp + +MCP-сервер: агрегирует статусы задач (`.tasks/STATUS.md`) и общие знания (репо `projects-wiki`, локально клонится в `~/projects/.wiki/`) поверх множества проектов и нескольких машин. + +- Источник правды: Gitea (`https://git.kzntsv.site`, owner `OpeItcLoc03`). +- Процесс — локальный на каждой машине (offline-критично). +- Данные — локальный кэш + клон `projects-wiki`, синхронизируются скриптом. + +## Bootstrap + +```bash +git clone https://git.kzntsv.site/OpeItcLoc03/projects-meta-mcp ~/.local/projects-meta-mcp +cd ~/.local/projects-meta-mcp +npm install && npm run build + +git clone https://git.kzntsv.site/OpeItcLoc03/projects-wiki ~/projects/.wiki + +mkdir -p ~/.config/projects-mcp +cp auth.toml.example ~/.config/projects-mcp/auth.toml +# отредактировать auth.toml — заполнить gitea_token + +node dist/sync.js # первый sync +``` + +## Регистрация в Claude Code + +Добавить в `~/.claude.json`: + +```json +{ + "mcpServers": { + "projects-meta": { + "command": "node", + "args": ["C:/Users//.local/projects-meta-mcp/dist/server.js"] + } + } +} +``` + +## Tools + +| Tool | Назначение | +|------|------------| +| `tasks.aggregate` | Свод активных задач по всем проектам | +| `tasks.search` | Поиск по slug/next-action | +| `tasks.get` | Полный STATUS.md одного проекта | +| `knowledge.search` | Поиск по shared-вики, фильтр по домену cwd | +| `knowledge.get` | Полный текст одной страницы | +| `knowledge.suggest_promote` | Кандидаты на промоушен из `.wiki/concepts/` текущего проекта | +| `meta.status` | Возраст кэша + диагностика | + +См. дизайн: [docs/superpowers/specs/2026-04-29-projects-meta-mcp-design.md](docs/superpowers/specs/2026-04-29-projects-meta-mcp-design.md) +План реализации: [docs/superpowers/plans/2026-04-29-bootstrap-implementation.md](docs/superpowers/plans/2026-04-29-bootstrap-implementation.md) +``` + +- [ ] **Step 2: Add task block to .tasks/STATUS.md** + +After the comment block, append: + +```markdown + +## 🟢 [bootstrap-implementation] — Реализация sync-script + MCP-сервера по спеке + +**Status:** done +**Where I stopped:** Все фазы плана выполнены, тесты зелёные, README обновлён. +**Next action:** Юзкейс-проверка на реальной Gitea: положить `auth.toml`, запустить `node dist/sync.js`, дёрнуть MCP из Claude Code. +**Branch:** main + +--- +``` + +- [ ] **Step 3: Create .tasks/bootstrap-implementation.md** + +```markdown +# bootstrap-implementation + +## Goal +Реализовать минимальный рабочий `projects-meta-mcp`: sync-script тащит `STATUS.md` всех репо `OpeItcLoc03` через Gitea API в локальный JSON-кэш; MCP-сервер по stdio отдаёт `tasks.*`, `knowledge.*`, `meta.status`. Конец = `npm test` зелёный, `npm run build` успешен, README с bootstrap-инструкцией. + +## Key files +- `src/sync.ts` — CLI sync entry +- `src/server.ts` — MCP stdio entry +- `src/lib/config.ts:1` — paths + auth.toml +- `src/lib/gitea.ts:1` — Gitea API wrapper +- `src/lib/sync-runner.ts:1` — orchestrator (concurrency, error-collect) +- `src/lib/status-md.ts:1` — STATUS.md parser +- `src/lib/wiki-index.ts:1` — projects-wiki page loader +- `src/lib/domain-detector.ts:1` — cwd domain heuristic +- `src/lib/promotion.ts:1` — suggest_promote heuristic +- `src/tools/{tasks,knowledge,meta}.ts` — tool factories + +## Decisions log +- 2026-04-29: ESM (`"type": "module"`) + NodeNext — ради native fetch и top-level await. +- 2026-04-29: Vitest вместо jest — нативный ESM, быстрее. +- 2026-04-29: `gray-matter` для frontmatter, `smol-toml` для auth.toml, `zod` для tool input validation. +- 2026-04-29: Concurrency limit 10 для Gitea — соответствует спеке. +- 2026-04-29: Stale threshold 24h в `meta.status` — соответствует спеке. + +## Open questions +- [ ] Нужно ли в `getRawFile` пытаться `master` если `default_branch` не сработал? (пока: только `default_branch`). +- [ ] Cron / git-hook для запуска sync — вне scope этого таска. + +## Completed steps +- [x] Phase 1 — bootstrap (package.json, tsconfig, vitest, auth.toml.example) +- [x] Phase 2 — sync (config, cache, status-md, gitea, sync-runner, sync.ts) +- [x] Phase 3 — server (stdio, tasks tools, domain-detector, knowledge tools, meta, promotion) +- [x] Phase 4 — wiring (README, .tasks, .wiki entities/packages) + +## Notes +Pre-merge проверка: положить `~/.config/projects-mcp/auth.toml` с реальным токеном, запустить `node dist/sync.js`, проверить `~/.cache/projects-mcp/tasks.json`. Затем зарегистрировать MCP в `~/.claude.json` и дёрнуть `meta.status` из Claude Code. +``` + +- [ ] **Step 4: Update .wiki/index.md** + +Replace `_Пусто. Появятся при реализации сервера._` and `_Пусто. Появятся при `npm install` зависимостей._` with: + +```markdown +## Сущности + +- [sync-script](entities/sync-script.md) — скрипт-синк Gitea API → JSON-кэш. +- [mcp-server](entities/mcp-server.md) — stdio MCP с тулами `tasks.*`, `knowledge.*`, `meta.*`. +- [domain-detector](entities/domain-detector.md) — определение домена cwd по файлам. + +## Пакеты + +- [@modelcontextprotocol/sdk](packages/modelcontextprotocol-sdk.md) — MCP runtime. +- [gray-matter](packages/gray-matter.md) — frontmatter parser. +- [smol-toml](packages/smol-toml.md) — TOML parser для auth.toml. +- [zod](packages/zod.md) — runtime валидация tool-инпутов. +``` + +- [ ] **Step 5: Create .wiki/entities/sync-script.md** + +```markdown +--- +title: sync-script +type: entity +tags: [gitea, cache, cli] +sources: [] +updated: 2026-04-29 +--- + +# sync-script + +Локальный CLI-скрипт. Тащит `STATUS.md` всех репо пользователя через Gitea API, парсит, складывает в `~/.cache/projects-mcp/tasks.json` атомарной записью. + +## Файлы + +- [src/sync.ts](../../src/sync.ts) — entrypoint, читает `auth.toml`, вызывает runner, пишет кэш и лог. +- [src/lib/sync-runner.ts](../../src/lib/sync-runner.ts) — pure orchestrator: `listUserRepos` → параллельно (concurrency=10) `getRawFile` + `parseStatusMd` → собирает `CacheFile`. Тестируется юнитами через моки. +- [src/lib/gitea.ts](../../src/lib/gitea.ts) — fetch-wrapper, пагинация, 404 → `null`. +- [src/lib/cache.ts](../../src/lib/cache.ts) — атомарная запись через `tasks.json.tmp` + `rename`. + +## Контракт + +`runSync({ client, parseStatus, now, machine, giteaUrl, user, concurrency? })` → `CacheFile`. + +Ошибки сетевых запросов на конкретный репо не валят весь синк — оседают в `errors[]`. 404 на `STATUS.md` = у проекта нет тасок (skip без ошибки). + +## Запуск + +```bash +node dist/sync.js +``` + +Exit codes: +- `0` — sync прошёл (даже с частичными ошибками). +- `2` — `auth.toml` отсутствует / битый. +``` + +- [ ] **Step 6: Create .wiki/entities/mcp-server.md** + +```markdown +--- +title: mcp-server +type: entity +tags: [mcp, stdio] +sources: [] +updated: 2026-04-29 +--- + +# mcp-server + +stdio MCP-сервер. Не делает сетевых вызовов в hot path. Читает локальный кэш и клон `projects-wiki`. + +## Tools + +| Name | Файл | +|------|------| +| `tasks.aggregate`, `tasks.search`, `tasks.get` | [src/tools/tasks.ts](../../src/tools/tasks.ts) | +| `knowledge.search`, `knowledge.get`, `knowledge.suggest_promote` | [src/tools/knowledge.ts](../../src/tools/knowledge.ts) | +| `meta.status` | [src/tools/meta.ts](../../src/tools/meta.ts) | + +## CLI args + +- `--cwd ` — переопределение cwd (для тестов и nonstandard launchers). Иначе `process.cwd()`. + +## Запуск + +```bash +node dist/server.js +``` + +Транспорт — stdio. Регистрируется в `~/.claude.json` как `mcpServers.projects-meta`. +``` + +- [ ] **Step 7: Create .wiki/entities/domain-detector.md** + +```markdown +--- +title: domain-detector +type: entity +tags: [heuristic] +sources: [] +updated: 2026-04-29 +--- + +# domain-detector + +Определяет домен текущего проекта по файлам в cwd. Используется `knowledge.search` для дефолтного фильтра и `knowledge.suggest_promote` для предлагаемого `domain` в кандидате. + +## Алгоритм + +1. `package.json` → `node` +2. `platformio.ini` / `*.ino` / `CMakeLists.txt` с `arm-none-eabi`/`STM32`/`ESP_IDF` → `embedded` +3. `next.config.*` / `vite.config.*` / `index.html` (без package.json) → `web` +4. иначе → `unknown` + +`package.json` имеет высший приоритет — node-проект со статиком всё равно `node`. + +## Файл + +- [src/lib/domain-detector.ts](../../src/lib/domain-detector.ts) — pure async function. Тесты — [tests/lib/domain-detector.test.ts](../../tests/lib/domain-detector.test.ts). +``` + +- [ ] **Step 8: Create package pages** + +`.wiki/packages/modelcontextprotocol-sdk.md`: +```markdown +--- +title: "@modelcontextprotocol/sdk" +type: package +tags: [mcp, runtime] +updated: 2026-04-29 +--- + +# @modelcontextprotocol/sdk + +Официальный TypeScript SDK для MCP. Используется только в [src/server.ts](../../src/server.ts). + +- `Server` + `StdioServerTransport` для stdio-режима. +- Хендлеры регистрируются через `setRequestHandler(ListToolsRequestSchema | CallToolRequestSchema, ...)`. +- Для входных схем тулов используется JSON Schema (objects). Runtime-валидация — наша через `zod`. +``` + +`.wiki/packages/gray-matter.md`: +```markdown +--- +title: gray-matter +type: package +tags: [yaml, frontmatter] +updated: 2026-04-29 +--- + +# gray-matter + +Парсит YAML frontmatter в markdown. Используется в: +- [src/lib/wiki-index.ts](../../src/lib/wiki-index.ts) — `loadWiki()` +- [src/lib/promotion.ts](../../src/lib/promotion.ts) — `findCandidates()` + +API: `matter(text) → { data, content }`. +``` + +`.wiki/packages/smol-toml.md`: +```markdown +--- +title: smol-toml +type: package +tags: [toml] +updated: 2026-04-29 +--- + +# smol-toml + +TOML парсер без зависимостей. Используется в [src/lib/config.ts](../../src/lib/config.ts) для `~/.config/projects-mcp/auth.toml`. + +API: `parse(text) → object`. + +Альтернатива `@iarna/toml` отвергнута — у smol-toml нет depend tree. +``` + +`.wiki/packages/zod.md`: +```markdown +--- +title: zod +type: package +tags: [validation] +updated: 2026-04-29 +--- + +# zod + +Runtime-валидация. Используется в: +- [src/lib/config.ts](../../src/lib/config.ts) — схема `auth.toml` +- [src/tools/tasks.ts](../../src/tools/tasks.ts) — input-схемы tasks-тулов +- [src/tools/knowledge.ts](../../src/tools/knowledge.ts) — input-схемы knowledge-тулов + +JSON Schema, который видит MCP-клиент, прописан вручную (см. `inputSchema` каждого tool). zod — отдельный санитайзинг внутри handler'а, чтобы поймать малформед-аргументы. +``` + +- [ ] **Step 9: Append to .wiki/log.md** + +```markdown + +## [2026-04-29] init | Реализация sync-script + MCP-сервера + +- Добавлены entity-страницы: sync-script, mcp-server, domain-detector. +- Добавлены package-страницы: @modelcontextprotocol/sdk, gray-matter, smol-toml, zod. +- index.md обновлён: сущности и пакеты больше не пустые. +- README.md теперь содержит bootstrap-инструкцию + tools-таблицу. +- Тесты зелёные, build чистый. +``` + +- [ ] **Step 10: Commit** + +```bash +git add README.md .tasks/STATUS.md .tasks/bootstrap-implementation.md .wiki/index.md .wiki/log.md .wiki/entities .wiki/packages +git commit -m "docs: README bootstrap guide + wiki entity/package pages + task closeout" +``` + +--- + +### Task 4.3: Push to Gitea + +- [ ] **Step 1: Push main** + +```bash +git push origin main +``` + +Expected: Multiple commits pushed cleanly. Verify on Gitea UI: [git.kzntsv.site/OpeItcLoc03/projects-meta-mcp](https://git.kzntsv.site/OpeItcLoc03/projects-meta-mcp). + +- [ ] **Step 2: No further commits in this plan.** + +--- + +## Verification checklist (post-plan) + +After all tasks done: + +- [ ] `npm test` exits 0, all suites pass +- [ ] `npm run build` produces `dist/sync.js` and `dist/server.js` without errors +- [ ] `npm run typecheck` clean +- [ ] `node dist/sync.js` without auth.toml → exit 2 with helpful message +- [ ] `node dist/server.js` starts on stdio without crashing +- [ ] `git log --oneline` shows linear history of feat/chore/docs commits +- [ ] `git push origin main` succeeded +- [ ] [.tasks/STATUS.md](../../.tasks/STATUS.md) lists task as 🟢 done +- [ ] [.wiki/index.md](../../.wiki/index.md) lists 3 entities + 4 packages + +End state: pre-merge ready. To exercise live: write `~/.config/projects-mcp/auth.toml`, run `node dist/sync.js`, register MCP in Claude Code, call `meta.status` from a chat session. diff --git a/lib/projects-meta-mcp/docs/superpowers/plans/2026-04-30-federated-knowledge-ask-projects.md b/lib/projects-meta-mcp/docs/superpowers/plans/2026-04-30-federated-knowledge-ask-projects.md new file mode 100644 index 0000000..651c822 --- /dev/null +++ b/lib/projects-meta-mcp/docs/superpowers/plans/2026-04-30-federated-knowledge-ask-projects.md @@ -0,0 +1,1091 @@ +# Federated knowledge — `knowledge.ask_projects` / `get_from` Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Расширить `projects-meta-mcp` двумя read-only тулами, позволяющими опросить `.wiki/` указанных проектов, если в shared wiki ответа нет. Постановка задачи на оформление знания идёт через уже существующий `tasks.create` — новых сущностей не плодим. + +**Architecture:** `runSync` дополнительно тянет `.wiki/index.md` каждого репо в существующий `cache.json` (новое поле `ProjectStatus.wiki_index`). Новый парсер извлекает `{slug, type, title}` из canonical index. Тул `knowledge.ask_projects` фильтрует по index из кэша, потом live через Gitea API тянет top-K матчей. Тул `knowledge.get_from` тянет одну страницу live. Никаких новых конфигов и кэш-файлов. + +**Tech Stack:** TypeScript, vitest, Gitea Contents API через `Backend` interface, `gray-matter` для frontmatter (только если потребуется — парсер index.md работает с plain markdown). + +**Spec:** [`docs/superpowers/specs/2026-04-30-federated-knowledge-ask-projects-design.md`](../specs/2026-04-30-federated-knowledge-ask-projects-design.md) + +**Branching policy:** Всё прямо в `main` — отдельные feature-branches не используем (per user preference). + +--- + +## File Structure + +**Create:** +- `src/lib/wiki-index-parser.ts` — парсер canonical `.wiki/index.md` → `Array<{slug, type, title}>` +- `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` — два новых тула в `makeKnowledgeTools`, расширить `Opts` +- `tests/lib/sync-runner.test.ts` — переписать `fakeClient` под path-aware mock + добавить тесты на `wiki_index` + +`src/server.ts` уже пробрасывает `cacheFile`, `backend`, `giteaUser` — менять не надо. + +--- + +## Task 1: Index parser + +**Files:** +- Create: `src/lib/wiki-index-parser.ts` +- Test: `tests/lib/wiki-index-parser.test.ts` + +Парсер canonical `.wiki/index.md` (формат генерится `freshIndexMd` / `insertIndexEntry` в [src/lib/wiki-writer.ts](../../src/lib/wiki-writer.ts)). Структура: + +``` +## Entities + +- [some/slug](entities/some/slug.md) — Some Title +- [other](entities/other.md) — Other + +## Concepts + + +``` + +Парсер: +- Идёт по секциям `## Entities` / `## Concepts` / `## Packages` / `## Sources` / `## Raw`. +- В каждой собирает строки вида `- [slug](type/slug.md) — title` (тире — Unicode em-dash `—`). +- Игнорирует placeholder `` и пустые строки. +- Возвращает `Array<{slug, type, title}>` — `slug` хранится с type-префиксом (`"concepts/foo"`) для совместимости с API tools. + +- [ ] **Step 1.1: Write the failing test** + +`tests/lib/wiki-index-parser.test.ts`: +```typescript +import { describe, it, expect } from 'vitest'; +import { parseWikiIndex } from '../../src/lib/wiki-index-parser.js'; + +describe('parseWikiIndex', () => { + it('extracts entries from canonical index.md', () => { + const md = `# Wiki Index + +## Overview + +- [overview.md](overview.md) — project overview + +## Entities + +- [some-entity](entities/some-entity.md) — Some Entity Title + +## Concepts + +- [foo](concepts/foo.md) — Foo Concept +- [bar/baz](concepts/bar/baz.md) — Nested Bar Baz + +## Packages + + + +## Sources + + +`; + const entries = parseWikiIndex(md); + expect(entries).toEqual([ + { slug: 'entities/some-entity', type: 'entities', title: 'Some Entity Title' }, + { slug: 'concepts/foo', type: 'concepts', title: 'Foo Concept' }, + { slug: 'concepts/bar/baz', type: 'concepts', title: 'Nested Bar Baz' }, + ]); + }); + + it('returns empty array when all sections are empty', () => { + const md = `# Wiki Index + +## Entities + + + +## Concepts + + +`; + expect(parseWikiIndex(md)).toEqual([]); + }); + + it('returns empty array on completely malformed input', () => { + expect(parseWikiIndex('not markdown at all')).toEqual([]); + expect(parseWikiIndex('')).toEqual([]); + }); + + it('skips Overview section (special-cased: link is to overview.md, not type/slug.md)', () => { + const md = `# Wiki Index\n\n## Overview\n\n- [overview.md](overview.md) — project overview\n`; + expect(parseWikiIndex(md)).toEqual([]); + }); + + it('skips entries whose link does not match expected type/slug.md pattern', () => { + const md = `# Wiki Index + +## Concepts + +- [external](https://example.com) — External Link +- [proper](concepts/proper.md) — Proper Entry +`; + expect(parseWikiIndex(md)).toEqual([ + { slug: 'concepts/proper', type: 'concepts', title: 'Proper Entry' }, + ]); + }); + + it('handles Raw section', () => { + const md = `# Wiki Index\n\n## Raw\n\n- [doc](raw/doc.md) — Some PDF\n`; + expect(parseWikiIndex(md)).toEqual([ + { slug: 'raw/doc', type: 'raw', title: 'Some PDF' }, + ]); + }); +}); +``` + +- [ ] **Step 1.2: Run tests and verify they fail** + +Run: `npx vitest run tests/lib/wiki-index-parser.test.ts` +Expected: FAIL with "Cannot find module '../../src/lib/wiki-index-parser.js'" + +- [ ] **Step 1.3: Implement the parser** + +`src/lib/wiki-index-parser.ts`: +```typescript +import type { WikiPageType } from './wiki-writer.js'; + +export interface WikiIndexEntry { + /** Slug includes the type prefix, e.g. "concepts/foo" or "entities/bar/baz". */ + slug: string; + type: WikiPageType; + title: string; +} + +const SECTION_TYPES: Record = { + Entities: 'entities', + Concepts: 'concepts', + Packages: 'packages', + Sources: 'sources', + Raw: 'raw', +}; + +const ENTRY_RE = /^- \[(?