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>
39 KiB
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
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.tstests/tools/knowledge-ask-projects.test.tstests/tools/knowledge-get-from.test.ts
Modify:
src/lib/cache.ts— добавитьwiki_index?: stringвProjectStatussrc/lib/sync-runner.ts— параллельныйgetRawFileдля.wiki/index.mdsrc/tools/knowledge.ts— два новых тула вmakeKnowledgeTools, расширитьOptstests/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). Структура:
## 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:
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:
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
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(rewritefakeClientshape + add new tests) -
Step 2.1: Add
wiki_indexfield toProjectStatus
Edit src/lib/cache.ts lines 10–17:
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
fakeClientin existing sync-runner test to be path-aware
Edit tests/lib/sync-runner.test.ts. Replace fakeClient (lines 6–23) with:
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_indexcapture
Append to tests/lib/sync-runner.test.ts (inside the same describe('runSync') block):
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
runSyncto fetch.wiki/index.mdin parallel
Edit src/lib/sync-runner.ts. Replace the per-repo worker (lines 61–84) with:
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
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 tomakeKnowledgeTools) - 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:
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:
- At the top, after existing imports:
import { readCache } from '../lib/cache.js';
import { parseWikiIndex, type WikiIndexEntry } from '../lib/wiki-index-parser.js';
- Add Zod schemas next to existing ones:
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(),
});
- Add helper functions near the existing
snippet/pageMatches:
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);
}
- Inside the returned tools array (in
makeKnowledgeTools), add a new tool entry before the existingknowledge.ingest:
{
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
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:
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
GetFromInputschema and tool
In src/tools/knowledge.ts, add Zod schema next to AskProjectsInput:
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):
{
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
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 lines 39–48. It already passes cacheFile, backend, giteaUser. No server change needed. Verify tools register:
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:
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_indexfield) — Task 2 - ✓ Cache schema field — Task 2 step 2.1
- ✓ Index parser — Task 1
- ✓ Default
types(raw excluded) — covered by test in Task 3.1excludes 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 testnext_action_hint mentions both - ✓ Description тула упоминает цепочку с
tasks.create— Task 3.3 description string
Type/signature consistency:
WikiIndexEntry.slugincludes type-prefix throughout ("concepts/foo").parseWikiIndexsignature:(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.