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>
1092 lines
39 KiB
Markdown
1092 lines
39 KiB
Markdown
# 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 10–17:
|
||
```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 6–23) 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 61–84) 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 39–48. 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.
|