Files
factory/lib/projects-meta-mcp/docs/superpowers/plans/2026-04-30-federated-knowledge-ask-projects.md
vitya ca669d96e1 feat(lib): bundle MCP servers from .common/lib into factory/lib/
Copies source (no node_modules, dist, .tasks, .wiki, __pycache__) for:
- projects-meta-mcp v2.25.0 (TypeScript/Node)
- wiki-graph v0.3.1 (TypeScript/Node)
- interns-mcp v0.3.3 (Python/FastMCP)

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 13:17:29 +03:00

1092 lines
39 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
<!-- (none yet) -->
```
Парсер:
- Идёт по секциям `## Entities` / `## Concepts` / `## Packages` / `## Sources` / `## Raw`.
- В каждой собирает строки вида `- [slug](type/slug.md) — title` (тире — Unicode em-dash `—`).
- Игнорирует placeholder `<!-- (none yet) -->` и пустые строки.
- Возвращает `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
<!-- (none yet) -->
## Sources
<!-- (none yet) -->
`;
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
<!-- (none yet) -->
## Concepts
<!-- (none yet) -->
`;
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<string, WikiPageType> = {
Entities: 'entities',
Concepts: 'concepts',
Packages: 'packages',
Sources: 'sources',
Raw: 'raw',
};
const ENTRY_RE = /^- \[(?<label>[^\]]+)\]\((?<href>[^)]+)\)\s*—\s*(?<title>.+?)\s*$/;
export function parseWikiIndex(md: string): WikiIndexEntry[] {
if (!md || typeof md !== 'string') return [];
const lines = md.split(/\r?\n/);
const out: WikiIndexEntry[] = [];
let currentType: WikiPageType | null = null;
for (const line of lines) {
const headMatch = /^##\s+(.+?)\s*$/.exec(line);
if (headMatch) {
currentType = SECTION_TYPES[headMatch[1]] ?? null;
continue;
}
if (!currentType) continue;
const entryMatch = ENTRY_RE.exec(line);
if (!entryMatch?.groups) continue;
const href = entryMatch.groups.href;
const title = entryMatch.groups.title;
// href must look like `<currentType>/<rest>.md`
const expectedPrefix = `${currentType}/`;
if (!href.startsWith(expectedPrefix) || !href.endsWith('.md')) continue;
const slugPart = href.slice(0, -'.md'.length); // strip ".md"
out.push({ slug: slugPart, type: currentType, title });
}
return out;
}
```
- [ ] **Step 1.4: Run tests and verify they pass**
Run: `npx vitest run tests/lib/wiki-index-parser.test.ts`
Expected: PASS — all 6 tests green.
- [ ] **Step 1.5: Commit**
```bash
git add src/lib/wiki-index-parser.ts tests/lib/wiki-index-parser.test.ts
git commit -m "feat(wiki): parser for canonical .wiki/index.md catalog"
```
---
## Task 2: Cache schema + sync extension
**Files:**
- Modify: `src/lib/cache.ts` (add field)
- Modify: `src/lib/sync-runner.ts` (parallel fetch)
- Modify: `tests/lib/sync-runner.test.ts` (rewrite `fakeClient` shape + add new tests)
- [ ] **Step 2.1: Add `wiki_index` field to `ProjectStatus`**
Edit `src/lib/cache.ts` lines 1017:
```typescript
export interface ProjectStatus {
name: string;
default_branch: string;
fetched_at: string;
active_tasks: ActiveTask[];
all_tasks_count: number;
raw: string;
/** Raw .wiki/index.md, if the repo has one. Used by knowledge.ask_projects. */
wiki_index?: string;
}
```
- [ ] **Step 2.2: Rewrite `fakeClient` in existing sync-runner test to be path-aware**
Edit `tests/lib/sync-runner.test.ts`. Replace `fakeClient` (lines 623) with:
```typescript
type FileMap = Record<string, string | null | Error>;
type RepoFiles = string | null | Error | FileMap;
function fakeClient(
repos: Array<{ name: string; default_branch: string }>,
files: Record<string, RepoFiles>,
): Backend {
return {
async listUserRepos() {
return repos;
},
async getRawFile(_u, repo, path) {
const v = files[repo];
// Backwards-compat: bare string|null|Error means it's the STATUS.md content,
// and .wiki/index.md is treated as absent.
if (v === undefined) return null;
if (typeof v === 'string' || v === null || v instanceof Error) {
if (path === '.tasks/STATUS.md') {
if (v instanceof Error) throw v;
return v;
}
return null;
}
// FileMap: key by exact path
const inner = (v as FileMap)[path];
if (inner === undefined) return null;
if (inner instanceof Error) throw inner;
return inner;
},
async getFileWithSha() {
throw new Error('not used in sync tests');
},
async commitFile() {
throw new Error('not used in sync tests');
},
};
}
```
This keeps existing tests working unchanged (they pass plain strings).
- [ ] **Step 2.3: Run existing tests to confirm no regression**
Run: `npx vitest run tests/lib/sync-runner.test.ts`
Expected: PASS — all 12 existing tests still green.
- [ ] **Step 2.4: Add failing tests for `wiki_index` capture**
Append to `tests/lib/sync-runner.test.ts` (inside the same `describe('runSync')` block):
```typescript
it('captures wiki_index alongside STATUS.md when both exist', async () => {
const wikiIndex = `# Wiki Index\n\n## Concepts\n\n- [foo](concepts/foo.md) — Foo\n`;
const cache = await runSync({
client: fakeClient(
[{ name: 'a', default_branch: 'main' }],
{ a: { '.tasks/STATUS.md': sample, '.wiki/index.md': wikiIndex } },
),
parseStatus: parseStatusMd,
now,
machine: 'm',
giteaUrl: 'https://g',
user: 'u',
});
expect(cache.projects).toHaveLength(1);
expect(cache.projects[0].wiki_index).toBe(wikiIndex);
});
it('captures wiki-only repo (no STATUS.md) into projects list', async () => {
const wikiIndex = `# Wiki Index\n\n## Concepts\n\n- [foo](concepts/foo.md) — Foo\n`;
const cache = await runSync({
client: fakeClient(
[{ name: 'wiki-only', default_branch: 'main' }],
{ 'wiki-only': { '.tasks/STATUS.md': null, '.wiki/index.md': wikiIndex } },
),
parseStatus: parseStatusMd,
now,
machine: 'm',
giteaUrl: 'https://g',
user: 'u',
});
expect(cache.projects).toHaveLength(1);
expect(cache.projects[0].name).toBe('wiki-only');
expect(cache.projects[0].raw).toBe('');
expect(cache.projects[0].active_tasks).toEqual([]);
expect(cache.projects[0].all_tasks_count).toBe(0);
expect(cache.projects[0].wiki_index).toBe(wikiIndex);
});
it('omits wiki_index when .wiki/index.md is absent', async () => {
const cache = await runSync({
client: fakeClient(
[{ name: 'a', default_branch: 'main' }],
{ a: { '.tasks/STATUS.md': sample, '.wiki/index.md': null } },
),
parseStatus: parseStatusMd,
now,
machine: 'm',
giteaUrl: 'https://g',
user: 'u',
});
expect(cache.projects[0].wiki_index).toBeUndefined();
});
it('skips repo when neither STATUS.md nor wiki_index exist', async () => {
const cache = await runSync({
client: fakeClient(
[
{ name: 'a', default_branch: 'main' },
{ name: 'empty', default_branch: 'main' },
],
{
a: { '.tasks/STATUS.md': sample, '.wiki/index.md': null },
empty: { '.tasks/STATUS.md': null, '.wiki/index.md': 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('does not record error when wiki_index fetch fails — degrades silently', async () => {
const cache = await runSync({
client: fakeClient(
[{ name: 'a', default_branch: 'main' }],
{ a: { '.tasks/STATUS.md': sample, '.wiki/index.md': new Error('wiki fetch 500') } },
),
parseStatus: parseStatusMd,
now,
machine: 'm',
giteaUrl: 'https://g',
user: 'u',
});
expect(cache.projects).toHaveLength(1);
expect(cache.projects[0].wiki_index).toBeUndefined();
expect(cache.errors).toEqual([]);
});
```
- [ ] **Step 2.5: Run new tests and verify they fail**
Run: `npx vitest run tests/lib/sync-runner.test.ts`
Expected: FAIL — new tests fail because sync-runner doesn't yet fetch `.wiki/index.md`.
- [ ] **Step 2.6: Update `runSync` to fetch `.wiki/index.md` in parallel**
Edit `src/lib/sync-runner.ts`. Replace the per-repo worker (lines 6184) with:
```typescript
const results = await pool<typeof repos[number], Outcome>(repos, concurrency, async (repo) => {
try {
const [tasksRaw, wikiIndex] = await Promise.all([
deps.client.getRawFile(deps.user, repo.name, '.tasks/STATUS.md', repo.default_branch),
// Wiki fetch failure must not break the repo entry — degrade silently.
deps.client
.getRawFile(deps.user, repo.name, '.wiki/index.md', repo.default_branch)
.catch(() => null),
]);
// Skip repo only if BOTH absent.
if (tasksRaw === null && wikiIndex === null) return { kind: 'skip' as const };
const parsed = tasksRaw === null ? { tasks: [] as ParsedStatus['tasks'] } : deps.parseStatus(tasksRaw);
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: tasksRaw ?? '',
wiki_index: wikiIndex ?? undefined,
},
};
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
return { kind: 'error' as const, data: { project: repo.name, reason } };
}
});
```
Note: `parseStatus` import for the `ParsedStatus` type already exists at the top of the file.
- [ ] **Step 2.7: Run all sync tests and confirm everything passes**
Run: `npx vitest run tests/lib/sync-runner.test.ts`
Expected: PASS — original 12 tests + 5 new = 17 green.
- [ ] **Step 2.8: Commit**
```bash
git add src/lib/cache.ts src/lib/sync-runner.ts tests/lib/sync-runner.test.ts
git commit -m "feat(sync): tag .wiki/index.md alongside STATUS.md per project"
```
---
## Task 3: `knowledge.ask_projects` tool
**Files:**
- Modify: `src/tools/knowledge.ts` (add tool to `makeKnowledgeTools`)
- Create: `tests/tools/knowledge-ask-projects.test.ts`
The tool reads `cache.json`, finds the named projects, parses each one's cached `wiki_index`, filters by query and `types`, then live-fetches up to `limit` matching pages per project to extract snippets.
- [ ] **Step 3.1: Write failing tests**
`tests/tools/knowledge-ask-projects.test.ts`:
```typescript
import { describe, it, expect, beforeEach } from 'vitest';
import { mkdtemp, mkdir } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { makeKnowledgeTools } from '../../src/tools/knowledge.js';
import { writeCache, type CacheFile } from '../../src/lib/cache.js';
import type { Backend, CommitFileArgs, FileWithSha } from '../../src/lib/backend.js';
let wikiRoot: string;
let projectCwd: string;
let cacheFile: string;
beforeEach(async () => {
wikiRoot = await mkdtemp(join(tmpdir(), 'kw-shared-'));
await mkdir(wikiRoot, { recursive: true });
projectCwd = await mkdtemp(join(tmpdir(), 'kw-cwd-'));
cacheFile = join(await mkdtemp(join(tmpdir(), 'kw-cache-')), 'cache.json');
});
interface FetchedPage {
body: string;
}
function makeBackend(pages: Record<string, Record<string, FetchedPage | Error>>): Backend {
return {
async listUserRepos() { return []; },
async getRawFile(_u, repo, path) {
const repoPages = pages[repo];
if (!repoPages) return null;
const v = repoPages[path];
if (v === undefined) return null;
if (v instanceof Error) throw v;
return v.body;
},
async getFileWithSha(): Promise<FileWithSha | null> { return null; },
async commitFile(_a: CommitFileArgs) { throw new Error('not used'); },
};
}
async function seedCache(projects: CacheFile['projects']) {
const file: CacheFile = {
synced_at: '2026-04-30T00:00:00Z',
synced_from: 'https://g',
machine: 'm',
projects,
errors: [],
};
await writeCache(cacheFile, file);
}
function call(tools: ReturnType<typeof makeKnowledgeTools>, name: string, args: Record<string, unknown>) {
const t = tools.find((x) => x.name === name);
if (!t) throw new Error(`tool ${name} missing`);
return t.handler(args);
}
const sampleIndex = `# Wiki Index
## Concepts
- [yarn-on-windows](concepts/yarn-on-windows.md) — Yarn on Windows
- [other](concepts/other.md) — Other Concept
## Raw
- [pdf-dump](raw/pdf-dump.md) — Heavy PDF
`;
describe('knowledge.ask_projects', () => {
it('returns matches across listed projects with snippets', async () => {
await seedCache([
{
name: 'foo',
default_branch: 'main',
fetched_at: '2026-04-30T00:00:00Z',
active_tasks: [],
all_tasks_count: 0,
raw: '',
wiki_index: sampleIndex,
},
]);
const backend = makeBackend({
foo: {
'.wiki/concepts/yarn-on-windows.md': { body: '---\ntitle: Yarn on Windows\n---\n\nyarn install on windows is fiddly' },
},
});
const tools = makeKnowledgeTools({
wikiRoot, projectCwd, cacheFile, backend, giteaUser: 'u',
});
const r = await call(tools, 'knowledge.ask_projects', { query: 'yarn', projects: ['foo'] });
const parsed = JSON.parse(r.content[0].text);
expect(parsed.asked_projects).toEqual(['foo']);
expect(parsed.results).toHaveLength(1);
expect(parsed.results[0].project).toBe('foo');
expect(parsed.results[0].matches).toHaveLength(1);
expect(parsed.results[0].matches[0].slug).toBe('concepts/yarn-on-windows');
expect(parsed.results[0].matches[0].type).toBe('concepts');
expect(parsed.results[0].matches[0].title).toBe('Yarn on Windows');
expect(parsed.results[0].matches[0].snippet).toContain('yarn');
});
it('reports "no wiki" for projects without wiki_index', async () => {
await seedCache([
{
name: 'no-wiki', default_branch: 'main', fetched_at: '2026-04-30T00:00:00Z',
active_tasks: [], all_tasks_count: 0, raw: '',
},
]);
const backend = makeBackend({});
const tools = makeKnowledgeTools({ wikiRoot, projectCwd, cacheFile, backend, giteaUser: 'u' });
const r = await call(tools, 'knowledge.ask_projects', { query: 'anything', projects: ['no-wiki'] });
const parsed = JSON.parse(r.content[0].text);
expect(parsed.results).toEqual([
{ project: 'no-wiki', error: 'no wiki — repo has no .wiki/index.md' },
]);
});
it('reports "unknown project" when project not in cache', async () => {
await seedCache([]);
const backend = makeBackend({});
const tools = makeKnowledgeTools({ wikiRoot, projectCwd, cacheFile, backend, giteaUser: 'u' });
const r = await call(tools, 'knowledge.ask_projects', { query: 'x', projects: ['ghost'] });
const parsed = JSON.parse(r.content[0].text);
expect(parsed.results).toEqual([
{ project: 'ghost', error: 'unknown project — run sync first' },
]);
});
it('excludes raw type by default', async () => {
await seedCache([
{
name: 'foo', default_branch: 'main', fetched_at: '2026-04-30T00:00:00Z',
active_tasks: [], all_tasks_count: 0, raw: '', wiki_index: sampleIndex,
},
]);
const backend = makeBackend({
foo: {
'.wiki/raw/pdf-dump.md': { body: 'pdf about pdf' },
},
});
const tools = makeKnowledgeTools({ wikiRoot, projectCwd, cacheFile, backend, giteaUser: 'u' });
const r = await call(tools, 'knowledge.ask_projects', { query: 'pdf', projects: ['foo'] });
const parsed = JSON.parse(r.content[0].text);
// raw match should be filtered out by default types
expect(parsed.results[0].matches).toEqual([]);
});
it('includes raw when explicitly asked', async () => {
await seedCache([
{
name: 'foo', default_branch: 'main', fetched_at: '2026-04-30T00:00:00Z',
active_tasks: [], all_tasks_count: 0, raw: '', wiki_index: sampleIndex,
},
]);
const backend = makeBackend({
foo: { '.wiki/raw/pdf-dump.md': { body: 'pdf body' } },
});
const tools = makeKnowledgeTools({ wikiRoot, projectCwd, cacheFile, backend, giteaUser: 'u' });
const r = await call(tools, 'knowledge.ask_projects', {
query: 'pdf', projects: ['foo'], types: ['raw'],
});
const parsed = JSON.parse(r.content[0].text);
expect(parsed.results[0].matches).toHaveLength(1);
expect(parsed.results[0].matches[0].slug).toBe('raw/pdf-dump');
});
it('respects limit per project', async () => {
const wideIndex = `# Wiki Index\n\n## Concepts\n\n- [a](concepts/a.md) — A\n- [b](concepts/b.md) — B\n- [c](concepts/c.md) — C\n`;
await seedCache([
{
name: 'foo', default_branch: 'main', fetched_at: '2026-04-30T00:00:00Z',
active_tasks: [], all_tasks_count: 0, raw: '', wiki_index: wideIndex,
},
]);
const backend = makeBackend({
foo: {
'.wiki/concepts/a.md': { body: '' },
'.wiki/concepts/b.md': { body: '' },
'.wiki/concepts/c.md': { body: '' },
},
});
const tools = makeKnowledgeTools({ wikiRoot, projectCwd, cacheFile, backend, giteaUser: 'u' });
const r = await call(tools, 'knowledge.ask_projects', {
query: '', projects: ['foo'], limit: 2,
});
const parsed = JSON.parse(r.content[0].text);
expect(parsed.results[0].matches).toHaveLength(2);
});
it('marks snippet as "(fetch failed)" when live fetch throws', async () => {
await seedCache([
{
name: 'foo', default_branch: 'main', fetched_at: '2026-04-30T00:00:00Z',
active_tasks: [], all_tasks_count: 0, raw: '', wiki_index: sampleIndex,
},
]);
const backend = makeBackend({
foo: {
'.wiki/concepts/yarn-on-windows.md': new Error('boom'),
},
});
const tools = makeKnowledgeTools({ wikiRoot, projectCwd, cacheFile, backend, giteaUser: 'u' });
const r = await call(tools, 'knowledge.ask_projects', { query: 'yarn', projects: ['foo'] });
const parsed = JSON.parse(r.content[0].text);
expect(parsed.results[0].matches[0].snippet).toBe('(fetch failed)');
expect(parsed.results[0].matches[0].slug).toBe('concepts/yarn-on-windows');
});
it('returns error when backend not configured', async () => {
await seedCache([]);
const tools = makeKnowledgeTools({ wikiRoot, projectCwd, cacheFile });
const r = await call(tools, 'knowledge.ask_projects', { query: 'x', projects: ['foo'] });
expect(r.isError).toBe(true);
expect(r.content[0].text).toContain('disabled');
});
it('next_action_hint mentions both knowledge.get_from and tasks.create', async () => {
await seedCache([]);
const tools = makeKnowledgeTools({
wikiRoot, projectCwd, cacheFile, backend: makeBackend({}), giteaUser: 'u',
});
const r = await call(tools, 'knowledge.ask_projects', { query: 'x', projects: ['foo'] });
const parsed = JSON.parse(r.content[0].text);
expect(parsed.next_action_hint).toContain('knowledge.get_from');
expect(parsed.next_action_hint).toContain('tasks.create');
});
});
```
- [ ] **Step 3.2: Run tests and verify they fail**
Run: `npx vitest run tests/tools/knowledge-ask-projects.test.ts`
Expected: FAIL — `tool knowledge.ask_projects missing`.
- [ ] **Step 3.3: Implement the tool**
Add to `src/tools/knowledge.ts`:
1. At the top, after existing imports:
```typescript
import { readCache } from '../lib/cache.js';
import { parseWikiIndex, type WikiIndexEntry } from '../lib/wiki-index-parser.js';
```
2. Add Zod schemas next to existing ones:
```typescript
const AskProjectsInput = z.object({
query: z.string(),
projects: z.array(z.string().min(1)).min(1),
limit: z.number().int().positive().max(20).optional(),
types: z
.array(z.enum(['entities', 'concepts', 'packages', 'sources', 'raw']))
.optional(),
});
```
3. Add helper functions near the existing `snippet` / `pageMatches`:
```typescript
const DEFAULT_ASK_TYPES: WikiPageType[] = ['entities', 'concepts', 'packages', 'sources'];
function indexEntryMatches(entry: WikiIndexEntry, query: string): boolean {
if (!query) return true;
const q = query.toLowerCase();
return entry.slug.toLowerCase().includes(q) || entry.title.toLowerCase().includes(q);
}
```
4. Inside the returned tools array (in `makeKnowledgeTools`), add a new tool entry **before** the existing `knowledge.ingest`:
```typescript
{
name: 'knowledge.ask_projects',
description:
'Опросить указанные другие проекты (их .wiki/) на предмет знания, если в shared (knowledge.search) ничего не нашлось. ' +
'Передай `projects: ["foo","bar"]` — имена репо. По умолчанию ищет в типах [entities, concepts, packages, sources] (raw исключён). ' +
'Возвращает per-project список матчей со снипетами. ' +
'Если знания нет ни у кого — поставь задачу через `tasks.create({target_project, body: "оформи знание о X в .wiki/concepts/x.md"})` (тоже доступно).',
inputSchema: {
type: 'object',
properties: {
query: { type: 'string' },
projects: { type: 'array', items: { type: 'string' }, minItems: 1 },
limit: { type: 'integer', minimum: 1, maximum: 20 },
types: {
type: 'array',
items: { type: 'string', enum: ['entities', 'concepts', 'packages', 'sources', 'raw'] },
},
},
required: ['query', 'projects'],
additionalProperties: false,
},
async handler(args) {
if (!opts.backend || !opts.giteaUser || !opts.cacheFile) {
return asError(
'knowledge.ask_projects disabled — projects-meta-mcp was started without auth.toml. ' +
'Configure ~/.config/projects-mcp/auth.toml and restart Claude Code.',
);
}
const input = AskProjectsInput.parse(args);
const limit = input.limit ?? 5;
const allowedTypes = new Set<WikiPageType>(input.types ?? DEFAULT_ASK_TYPES);
const cache = await readCache(opts.cacheFile);
const projectsMap = new Map((cache?.projects ?? []).map((p) => [p.name, p] as const));
const next_action_hint =
'knowledge.get_from(project, slug) для полного текста; ' +
'tasks.create(target_project, body) если знание не оформлено';
const results = await Promise.all(
input.projects.map(async (projectName) => {
const proj = projectsMap.get(projectName);
if (!proj) {
return { project: projectName, error: 'unknown project — run sync first' };
}
if (!proj.wiki_index) {
return { project: projectName, error: 'no wiki — repo has no .wiki/index.md' };
}
const entries = parseWikiIndex(proj.wiki_index)
.filter((e) => allowedTypes.has(e.type))
.filter((e) => indexEntryMatches(e, input.query))
.slice(0, limit);
const matches = await Promise.all(
entries.map(async (e) => {
const path = `.wiki/${e.slug}.md`;
let snip = '';
try {
const body = await opts.backend!.getRawFile(opts.giteaUser!, projectName, path, proj.default_branch);
if (body === null) {
snip = '(page missing in repo)';
} else {
snip = snippet(body, input.query);
}
} catch {
snip = '(fetch failed)';
}
return { slug: e.slug, type: e.type, title: e.title, snippet: snip };
}),
);
return { project: projectName, matches };
}),
);
return asJson({
asked_projects: input.projects,
results,
next_action_hint,
});
},
},
```
- [ ] **Step 3.4: Run tests and verify they pass**
Run: `npx vitest run tests/tools/knowledge-ask-projects.test.ts`
Expected: PASS — all 9 tests green.
- [ ] **Step 3.5: Run full suite to verify no regressions**
Run: `npx vitest run`
Expected: PASS — 130+ tests green (was 125; +6 from index parser, +5 from sync extension, +9 from ask_projects, some duplicate counts; actual count just needs to be all-green).
- [ ] **Step 3.6: Commit**
```bash
git add src/tools/knowledge.ts tests/tools/knowledge-ask-projects.test.ts
git commit -m "feat(knowledge): ask_projects — federate query across listed repos' .wiki/"
```
---
## Task 4: `knowledge.get_from` tool
**Files:**
- Modify: `src/tools/knowledge.ts` (add second tool)
- Create: `tests/tools/knowledge-get-from.test.ts`
- [ ] **Step 4.1: Write failing tests**
`tests/tools/knowledge-get-from.test.ts`:
```typescript
import { describe, it, expect, beforeEach } from 'vitest';
import { mkdtemp } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { makeKnowledgeTools } from '../../src/tools/knowledge.js';
import { writeCache, type CacheFile } from '../../src/lib/cache.js';
import type { Backend, CommitFileArgs, FileWithSha } from '../../src/lib/backend.js';
let wikiRoot: string;
let projectCwd: string;
let cacheFile: string;
beforeEach(async () => {
wikiRoot = await mkdtemp(join(tmpdir(), 'kw-shared-'));
projectCwd = await mkdtemp(join(tmpdir(), 'kw-cwd-'));
cacheFile = join(await mkdtemp(join(tmpdir(), 'kw-cache-')), 'cache.json');
});
function makeBackend(files: Record<string, Record<string, string | null | Error>>): Backend {
return {
async listUserRepos() { return []; },
async getRawFile(_u, repo, path) {
const v = files[repo]?.[path];
if (v === undefined) return null;
if (v instanceof Error) throw v;
return v;
},
async getFileWithSha(): Promise<FileWithSha | null> { return null; },
async commitFile(_a: CommitFileArgs) { throw new Error('not used'); },
};
}
async function seedCache(projects: CacheFile['projects']) {
await writeCache(cacheFile, {
synced_at: '2026-04-30T00:00:00Z',
synced_from: 'https://g',
machine: 'm',
projects,
errors: [],
});
}
function call(tools: ReturnType<typeof makeKnowledgeTools>, name: string, args: Record<string, unknown>) {
const t = tools.find((x) => x.name === name);
if (!t) throw new Error(`tool ${name} missing`);
return t.handler(args);
}
describe('knowledge.get_from', () => {
it('returns full page body for a known project + slug', async () => {
await seedCache([
{
name: 'foo', default_branch: 'main', fetched_at: '2026-04-30T00:00:00Z',
active_tasks: [], all_tasks_count: 0, raw: '', wiki_index: '',
},
]);
const body = '---\ntitle: Foo\n---\n\nfoo body content';
const backend = makeBackend({
foo: { '.wiki/concepts/foo.md': body },
});
const tools = makeKnowledgeTools({ wikiRoot, projectCwd, cacheFile, backend, giteaUser: 'u' });
const r = await call(tools, 'knowledge.get_from', { project: 'foo', slug: 'concepts/foo' });
expect(r.isError).toBeFalsy();
expect(r.content[0].text).toBe(body);
});
it('returns error when project not in cache', async () => {
await seedCache([]);
const backend = makeBackend({});
const tools = makeKnowledgeTools({ wikiRoot, projectCwd, cacheFile, backend, giteaUser: 'u' });
const r = await call(tools, 'knowledge.get_from', { project: 'ghost', slug: 'concepts/x' });
expect(r.isError).toBe(true);
expect(r.content[0].text).toContain('unknown project');
});
it('returns error when page does not exist', async () => {
await seedCache([
{
name: 'foo', default_branch: 'main', fetched_at: '2026-04-30T00:00:00Z',
active_tasks: [], all_tasks_count: 0, raw: '',
},
]);
const backend = makeBackend({ foo: {} });
const tools = makeKnowledgeTools({ wikiRoot, projectCwd, cacheFile, backend, giteaUser: 'u' });
const r = await call(tools, 'knowledge.get_from', { project: 'foo', slug: 'concepts/missing' });
expect(r.isError).toBe(true);
expect(r.content[0].text).toContain('page not found');
});
it('returns error when backend not configured', async () => {
await seedCache([]);
const tools = makeKnowledgeTools({ wikiRoot, projectCwd, cacheFile });
const r = await call(tools, 'knowledge.get_from', { project: 'foo', slug: 'concepts/x' });
expect(r.isError).toBe(true);
expect(r.content[0].text).toContain('disabled');
});
});
```
- [ ] **Step 4.2: Run tests and verify they fail**
Run: `npx vitest run tests/tools/knowledge-get-from.test.ts`
Expected: FAIL — `tool knowledge.get_from missing`.
- [ ] **Step 4.3: Add `GetFromInput` schema and tool**
In `src/tools/knowledge.ts`, add Zod schema next to `AskProjectsInput`:
```typescript
const GetFromInput = z.object({
project: z.string().min(1),
slug: z.string().min(1),
});
```
Add tool entry in the array, immediately after `knowledge.ask_projects` (still before `knowledge.ingest`):
```typescript
{
name: 'knowledge.get_from',
description:
'Полный текст одной страницы из конкретного проекта — `<project>/.wiki/<slug>.md`. ' +
'slug включает type-префикс (например "concepts/foo"). Используй после `knowledge.ask_projects` чтобы дочитать матч.',
inputSchema: {
type: 'object',
properties: {
project: { type: 'string' },
slug: { type: 'string' },
},
required: ['project', 'slug'],
additionalProperties: false,
},
async handler(args) {
if (!opts.backend || !opts.giteaUser || !opts.cacheFile) {
return asError(
'knowledge.get_from disabled — projects-meta-mcp was started without auth.toml.',
);
}
const input = GetFromInput.parse(args);
const cache = await readCache(opts.cacheFile);
const proj = (cache?.projects ?? []).find((p) => p.name === input.project);
if (!proj) {
return asError(`unknown project: ${input.project} — run sync first`);
}
const path = `.wiki/${input.slug}.md`;
try {
const body = await opts.backend.getRawFile(opts.giteaUser, input.project, path, proj.default_branch);
if (body === null) {
return asError(`page not found: ${input.project}/${path}`);
}
return { content: [{ type: 'text', text: body }] };
} catch (err) {
return asError(`fetch failed: ${err instanceof Error ? err.message : String(err)}`);
}
},
},
```
- [ ] **Step 4.4: Run tests and verify they pass**
Run: `npx vitest run tests/tools/knowledge-get-from.test.ts`
Expected: PASS — all 4 tests green.
- [ ] **Step 4.5: Run full suite**
Run: `npx vitest run`
Expected: PASS — full green.
- [ ] **Step 4.6: Commit**
```bash
git add src/tools/knowledge.ts tests/tools/knowledge-get-from.test.ts
git commit -m "feat(knowledge): get_from — fetch single page from named project"
```
---
## Task 5: Build + smoke check
**Files:**
- No new files. Verify wiring.
- [ ] **Step 5.1: Verify build**
Run: `npm run build`
Expected: clean — no TypeScript errors.
- [ ] **Step 5.2: Run full test suite once more**
Run: `npx vitest run`
Expected: PASS.
- [ ] **Step 5.3: Verify tool list at runtime**
The `makeKnowledgeTools` factory is called from [src/server.ts](../../src/server.ts) lines 3948. It already passes `cacheFile`, `backend`, `giteaUser`. No server change needed. Verify tools register:
```bash
node -e "
const m = require('./dist/tools/knowledge.js');
const tools = m.makeKnowledgeTools({ wikiRoot: '/tmp', projectCwd: '/tmp', cacheFile: '/tmp/c.json' });
console.log(tools.map(t => t.name));
"
```
Expected output (5 tools, two new ones present):
```
[
'knowledge.search',
'knowledge.get',
'knowledge.suggest_promote',
'knowledge.ask_projects',
'knowledge.get_from',
'knowledge.ingest',
'knowledge.promote'
]
```
(Order in your file determines order — confirm both new ones are listed.)
- [ ] **Step 5.4: Final commit (if anything was tweaked) and close out**
If everything was committed during prior tasks and `git status` is clean, skip this step. Otherwise:
```bash
git status
# If anything uncommitted:
git add <files>
git commit -m "chore: wire ask_projects/get_from in tool list"
```
---
## Self-Review Notes (for plan author)
**Spec coverage:**
-`knowledge.ask_projects` — Task 3
-`knowledge.get_from` — Task 4
- ✓ Sync extension (`wiki_index` field) — Task 2
- ✓ Cache schema field — Task 2 step 2.1
- ✓ Index parser — Task 1
- ✓ Default `types` (raw excluded) — covered by test in Task 3.1 `excludes raw type by default`
- ✓ All error branches from spec — covered in Task 3 tests (`no wiki`, `unknown project`, `(fetch failed)`, backend not configured)
- ✓ Hint про `tasks.create` в `next_action_hint` — covered in Task 3.1 test `next_action_hint mentions both`
- ✓ Description тула упоминает цепочку с `tasks.create` — Task 3.3 description string
**Type/signature consistency:**
- `WikiIndexEntry.slug` includes type-prefix throughout (`"concepts/foo"`).
- `parseWikiIndex` signature: `(md: string) => WikiIndexEntry[]` — used identically in Task 3.
- `ProjectStatus.wiki_index?: string` — used the same way in Task 2 sync, Task 3 tool, Task 4 tool.
- Tool names: `knowledge.ask_projects`, `knowledge.get_from` — consistent across spec, plan tasks, tests, and final smoke step.
**No placeholders found.** All test bodies and implementation snippets are complete.