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

39 KiB
Raw Blame History

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.mdArray<{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). Структура:

## 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 (rewrite fakeClient shape + add new tests)

  • Step 2.1: Add wiki_index field to ProjectStatus

Edit src/lib/cache.ts lines 1017:

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:

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):

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:

  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 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:

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:
import { readCache } from '../lib/cache.js';
import { parseWikiIndex, type WikiIndexEntry } from '../lib/wiki-index-parser.js';
  1. 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(),
});
  1. 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);
}
  1. Inside the returned tools array (in makeKnowledgeTools), add a new tool entry before the existing knowledge.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 GetFromInput schema 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 3948. 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_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.