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>
94 KiB
projects-meta-mcp Bootstrap Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Реализовать sync-script (Gitea API → JSON-кэш) и stdio MCP-сервер с тулами tasks.*, knowledge.*, meta.status — согласно спеке 2026-04-29-projects-meta-mcp-design.md. Конечное состояние: после npm run build и node dist/sync.js локальный ~/.cache/projects-mcp/tasks.json содержит реальные STATUS.md всех проектов owner'a OpeItcLoc03, node dist/server.js стартует stdio MCP и отвечает на все определённые tools.
Architecture: Один TS-пакет, два бинарника: dist/sync.js (CLI, делает сетевые вызовы) и dist/server.js (MCP stdio, читает только локальные файлы). Общая логика (парсеры, конфиг, кэш) — в src/lib/. ESM ("type": "module"), TypeScript NodeNext, native fetch.
Tech Stack: Node 22, TypeScript 5, @modelcontextprotocol/sdk, gray-matter (frontmatter), smol-toml (auth.toml), zod (схемы tool-инпутов), vitest (тесты).
File structure
projects-meta-mcp/
├── package.json
├── tsconfig.json
├── vitest.config.ts
├── auth.toml.example
├── .gitignore # дополнить
├── src/
│ ├── sync.ts # CLI entry: npm run sync
│ ├── server.ts # MCP entry: npm run start
│ ├── lib/
│ │ ├── config.ts # paths + auth.toml loader
│ │ ├── cache.ts # atomic JSON write/read
│ │ ├── gitea.ts # fetch wrapper for Gitea API
│ │ ├── status-md.ts # parse STATUS.md → ParsedStatus
│ │ ├── domain-detector.ts # detect cwd domain
│ │ ├── frontmatter.ts # parse wiki page frontmatter
│ │ └── promotion.ts # candidate heuristic
│ └── tools/
│ ├── tasks.ts # tasks.aggregate/search/get handlers
│ ├── knowledge.ts # knowledge.search/get/suggest_promote
│ └── meta.ts # meta.status
└── tests/
└── (mirrors src/lib/ and src/tools/)
File size discipline: lib/ files ≤ 150 lines each. Если файл вырастает — split.
Commits: один TDD-цикл (test → impl → green → commit) = один коммит. Сообщения в Conventional Commits, scope = file/module.
Phase 1 — Bootstrap
Task 1.1: Initialize npm package + TypeScript
Files:
-
Create:
package.json -
Create:
tsconfig.json -
Create:
vitest.config.ts -
Create:
src/sync.ts(skeleton) -
Create:
src/server.ts(skeleton) -
Step 1: Create package.json
{
"name": "projects-meta-mcp",
"version": "0.1.0",
"private": true,
"type": "module",
"bin": {
"projects-meta-mcp": "dist/server.js",
"projects-meta-sync": "dist/sync.js"
},
"scripts": {
"build": "tsc",
"start": "node dist/server.js",
"sync": "node dist/sync.js",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0",
"gray-matter": "^4.0.3",
"smol-toml": "^1.3.0",
"zod": "^3.23.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.6.0",
"vitest": "^2.1.0"
},
"engines": {
"node": ">=22"
}
}
- Step 2: Create tsconfig.json
{
"compilerOptions": {
"target": "ES2023",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2023"],
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": false,
"sourceMap": true,
"resolveJsonModule": true,
"verbatimModuleSyntax": true
},
"include": ["src/**/*"],
"exclude": ["dist", "node_modules", "tests"]
}
- Step 3: Create vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['tests/**/*.test.ts'],
environment: 'node',
},
});
- Step 4: Create src/sync.ts and src/server.ts skeletons
src/sync.ts:
async function main(): Promise<void> {
console.log('sync: not implemented yet');
}
await main();
src/server.ts:
async function main(): Promise<void> {
console.error('server: not implemented yet');
}
await main();
- Step 5: Run
npm install
Run: npm install
Expected: node_modules/ populated, package-lock.json created, no errors.
- Step 6: Verify build works
Run: npm run build && node dist/sync.js && node dist/server.js
Expected: skeleton outputs without crash. dist/ contains .js files.
- Step 7: Add dist/ and node_modules to .gitignore (already there)
Verify .gitignore already contains node_modules/ and dist/. If not, add.
- Step 8: Commit
git add package.json package-lock.json tsconfig.json vitest.config.ts src/sync.ts src/server.ts
git commit -m "chore: scaffold TS package with sync + server entries"
Task 1.2: Auth example + cache dir convention
Files:
-
Create:
auth.toml.example -
Step 1: Create auth.toml.example
# projects-meta-mcp auth config
# Copy to ~/.config/projects-mcp/auth.toml and fill gitea_token.
gitea_url = "https://git.kzntsv.site"
gitea_user = "OpeItcLoc03"
gitea_token = "REPLACE_WITH_PERSONAL_ACCESS_TOKEN" # scope: read:repository
- Step 2: Commit
git add auth.toml.example
git commit -m "chore: add auth.toml.example"
Phase 2 — sync-script
Task 2.1: Config loader
Files:
- Create:
src/lib/config.ts - Create:
tests/lib/config.test.ts
Contract:
export interface AuthConfig {
giteaUrl: string; // no trailing slash
giteaUser: string;
giteaToken: string;
}
export interface Paths {
cacheDir: string; // ~/.cache/projects-mcp
cacheFile: string; // <cacheDir>/tasks.json
syncLog: string; // <cacheDir>/sync.log
authFile: string; // ~/.config/projects-mcp/auth.toml
sharedWikiClone: string; // ~/projects/.wiki
}
export function getPaths(home: string): Paths;
export function loadAuth(authFile: string): Promise<AuthConfig>; // throws on missing/malformed
- Step 1: Write failing tests
tests/lib/config.test.ts:
import { describe, it, expect } from 'vitest';
import { mkdtemp, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { getPaths, loadAuth } from '../../src/lib/config.js';
describe('getPaths', () => {
it('builds canonical paths from $HOME', () => {
const p = getPaths('/home/u');
expect(p.cacheDir).toBe('/home/u/.cache/projects-mcp');
expect(p.cacheFile).toBe('/home/u/.cache/projects-mcp/tasks.json');
expect(p.authFile).toBe('/home/u/.config/projects-mcp/auth.toml');
expect(p.sharedWikiClone).toBe('/home/u/projects/.wiki');
});
});
describe('loadAuth', () => {
it('parses well-formed auth.toml', async () => {
const dir = await mkdtemp(join(tmpdir(), 'auth-'));
const file = join(dir, 'auth.toml');
await writeFile(
file,
[
'gitea_url = "https://git.kzntsv.site"',
'gitea_user = "OpeItcLoc03"',
'gitea_token = "abc123"',
].join('\n'),
);
const cfg = await loadAuth(file);
expect(cfg.giteaUrl).toBe('https://git.kzntsv.site');
expect(cfg.giteaUser).toBe('OpeItcLoc03');
expect(cfg.giteaToken).toBe('abc123');
});
it('strips trailing slash from gitea_url', async () => {
const dir = await mkdtemp(join(tmpdir(), 'auth-'));
const file = join(dir, 'auth.toml');
await writeFile(
file,
'gitea_url = "https://git.kzntsv.site/"\ngitea_user = "x"\ngitea_token = "y"\n',
);
const cfg = await loadAuth(file);
expect(cfg.giteaUrl).toBe('https://git.kzntsv.site');
});
it('throws on missing field', async () => {
const dir = await mkdtemp(join(tmpdir(), 'auth-'));
const file = join(dir, 'auth.toml');
await writeFile(file, 'gitea_url = "x"\ngitea_user = "y"\n');
await expect(loadAuth(file)).rejects.toThrow(/gitea_token/);
});
});
- Step 2: Run, expect FAIL
Run: npm test -- tests/lib/config.test.ts
Expected: FAIL — no src/lib/config.ts.
- Step 3: Implement src/lib/config.ts
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { parse as parseToml } from 'smol-toml';
import { z } from 'zod';
export interface AuthConfig {
giteaUrl: string;
giteaUser: string;
giteaToken: string;
}
export interface Paths {
cacheDir: string;
cacheFile: string;
syncLog: string;
authFile: string;
sharedWikiClone: string;
}
export function getPaths(home: string): Paths {
const cacheDir = join(home, '.cache', 'projects-mcp');
return {
cacheDir,
cacheFile: join(cacheDir, 'tasks.json'),
syncLog: join(cacheDir, 'sync.log'),
authFile: join(home, '.config', 'projects-mcp', 'auth.toml'),
sharedWikiClone: join(home, 'projects', '.wiki'),
};
}
const AuthSchema = z.object({
gitea_url: z.string().min(1),
gitea_user: z.string().min(1),
gitea_token: z.string().min(1),
});
export async function loadAuth(authFile: string): Promise<AuthConfig> {
const raw = await readFile(authFile, 'utf8');
const parsed = AuthSchema.parse(parseToml(raw));
return {
giteaUrl: parsed.gitea_url.replace(/\/+$/, ''),
giteaUser: parsed.gitea_user,
giteaToken: parsed.gitea_token,
};
}
- Step 4: Run, expect PASS
Run: npm test -- tests/lib/config.test.ts
Expected: 3 passed.
- Step 5: Commit
git add src/lib/config.ts tests/lib/config.test.ts
git commit -m "feat(config): add path resolver + auth.toml loader"
Task 2.2: Atomic cache writer
Files:
- Create:
src/lib/cache.ts - Create:
tests/lib/cache.test.ts
Contract:
export interface ProjectStatus {
name: string;
default_branch: string;
fetched_at: string; // ISO
active_tasks: ActiveTask[];
all_tasks_count: number;
raw: string; // full STATUS.md
}
export interface ActiveTask {
slug: string;
status: 'active' | 'paused' | 'blocked' | 'ready' | 'done';
next: string | null;
}
export interface SyncError {
project: string;
reason: string; // free-form
}
export interface CacheFile {
synced_at: string;
synced_from: string;
machine: string;
projects: ProjectStatus[];
errors: SyncError[];
}
export async function writeCache(file: string, data: CacheFile): Promise<void>;
export async function readCache(file: string): Promise<CacheFile | null>; // null when missing
- Step 1: Write failing tests
tests/lib/cache.test.ts:
import { describe, it, expect } from 'vitest';
import { mkdtemp, readFile, stat } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { writeCache, readCache, type CacheFile } from '../../src/lib/cache.js';
const sample: CacheFile = {
synced_at: '2026-04-29T12:00:00.000Z',
synced_from: 'https://git.kzntsv.site',
machine: 'test',
projects: [],
errors: [],
};
describe('cache', () => {
it('writes JSON atomically and reads it back', async () => {
const dir = await mkdtemp(join(tmpdir(), 'cache-'));
const file = join(dir, 'tasks.json');
await writeCache(file, sample);
const back = await readCache(file);
expect(back).toEqual(sample);
});
it('does not leave .tmp file on success', async () => {
const dir = await mkdtemp(join(tmpdir(), 'cache-'));
const file = join(dir, 'tasks.json');
await writeCache(file, sample);
await expect(stat(file + '.tmp')).rejects.toThrow();
});
it('returns null when cache file missing', async () => {
const dir = await mkdtemp(join(tmpdir(), 'cache-'));
const back = await readCache(join(dir, 'missing.json'));
expect(back).toBeNull();
});
it('creates parent directory if missing', async () => {
const dir = await mkdtemp(join(tmpdir(), 'cache-'));
const file = join(dir, 'nested', 'deep', 'tasks.json');
await writeCache(file, sample);
const txt = await readFile(file, 'utf8');
expect(JSON.parse(txt)).toEqual(sample);
});
});
- Step 2: Run, expect FAIL
Run: npm test -- tests/lib/cache.test.ts
Expected: FAIL — no module.
- Step 3: Implement src/lib/cache.ts
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import { dirname } from 'node:path';
export interface ActiveTask {
slug: string;
status: 'active' | 'paused' | 'blocked' | 'ready' | 'done';
next: string | null;
}
export interface ProjectStatus {
name: string;
default_branch: string;
fetched_at: string;
active_tasks: ActiveTask[];
all_tasks_count: number;
raw: string;
}
export interface SyncError {
project: string;
reason: string;
}
export interface CacheFile {
synced_at: string;
synced_from: string;
machine: string;
projects: ProjectStatus[];
errors: SyncError[];
}
export async function writeCache(file: string, data: CacheFile): Promise<void> {
await mkdir(dirname(file), { recursive: true });
const tmp = `${file}.tmp`;
await writeFile(tmp, JSON.stringify(data, null, 2), 'utf8');
await rename(tmp, file);
}
export async function readCache(file: string): Promise<CacheFile | null> {
try {
const txt = await readFile(file, 'utf8');
return JSON.parse(txt) as CacheFile;
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null;
throw err;
}
}
- Step 4: Run, expect PASS
Run: npm test -- tests/lib/cache.test.ts
Expected: 4 passed.
- Step 5: Commit
git add src/lib/cache.ts tests/lib/cache.test.ts
git commit -m "feat(cache): atomic tasks.json read/write"
Task 2.3: STATUS.md parser
Files:
- Create:
src/lib/status-md.ts - Create:
tests/lib/status-md.test.ts
Contract:
export interface ParsedTask {
slug: string;
status: 'active' | 'paused' | 'blocked' | 'ready' | 'done';
description: string;
next: string | null;
}
export interface ParsedStatus {
updated: string | null;
tasks: ParsedTask[];
}
export function parseStatusMd(text: string): ParsedStatus;
Format reference: Each task starts with ## <emoji> [<slug>] — <description>. Body has bold-prefixed fields. Emoji map: 🔴=active, 🟡=paused, ⚪=ready, 🟢=done, 🔵=blocked.
- Step 1: Write failing tests
tests/lib/status-md.test.ts:
import { describe, it, expect } from 'vitest';
import { parseStatusMd } from '../../src/lib/status-md.js';
const sample = `# Task Board
_Updated: 2026-04-29_
## 🔴 [auth-rewrite] — Rebuild OAuth pipeline
**Status:** active
**Where I stopped:** Mid-refactor of token validator
**Next action:** Add unit tests for refresh flow
**Branch:** feat/auth-rewrite
---
## 🟡 [docs-sweep] — Refresh README
**Status:** paused
**Where I stopped:** Drafted intro, blocked on screenshots
**Next action:** Capture login screencast
**Branch:** docs/readme
---
## 🔵 [vendor-bug] — Upstream pagination broken
**Status:** blocked
**Blocker:** Waiting on vendor patch v2.4.1
**Next action:** Re-run integration test once patched
**Branch:** fix/vendor-bug
---
`;
describe('parseStatusMd', () => {
it('extracts updated timestamp', () => {
const r = parseStatusMd(sample);
expect(r.updated).toBe('2026-04-29');
});
it('parses three tasks with correct statuses', () => {
const r = parseStatusMd(sample);
expect(r.tasks.map((t) => t.slug)).toEqual([
'auth-rewrite',
'docs-sweep',
'vendor-bug',
]);
expect(r.tasks.map((t) => t.status)).toEqual(['active', 'paused', 'blocked']);
});
it('captures description and next action', () => {
const r = parseStatusMd(sample);
expect(r.tasks[0].description).toBe('Rebuild OAuth pipeline');
expect(r.tasks[0].next).toBe('Add unit tests for refresh flow');
});
it('returns empty tasks when board empty', () => {
const r = parseStatusMd('# Task Board\n_Updated: 2026-04-29_\n');
expect(r.tasks).toEqual([]);
expect(r.updated).toBe('2026-04-29');
});
it('returns null updated when missing', () => {
expect(parseStatusMd('# Task Board\n').updated).toBeNull();
});
it('treats unknown emoji as ready (best effort)', () => {
const txt = '## ⚫ [weird-task] — desc\n**Next action:** something\n';
const r = parseStatusMd(txt);
expect(r.tasks[0].status).toBe('ready');
});
});
- Step 2: Run, expect FAIL
Run: npm test -- tests/lib/status-md.test.ts
Expected: FAIL — module missing.
- Step 3: Implement src/lib/status-md.ts
export type TaskStatus = 'active' | 'paused' | 'blocked' | 'ready' | 'done';
export interface ParsedTask {
slug: string;
status: TaskStatus;
description: string;
next: string | null;
}
export interface ParsedStatus {
updated: string | null;
tasks: ParsedTask[];
}
const EMOJI_TO_STATUS: Record<string, TaskStatus> = {
'🔴': 'active',
'🟡': 'paused',
'⚪': 'ready',
'🟢': 'done',
'🔵': 'blocked',
};
const TASK_HEADER = /^##\s+(\S+)\s+\[([^\]]+)\]\s+—\s+(.+)$/u;
const NEXT_FIELD = /^\*\*Next action:\*\*\s*(.+)$/i;
const UPDATED_FIELD = /^_Updated:\s*([0-9-]+)_/i;
export function parseStatusMd(text: string): ParsedStatus {
const lines = text.split(/\r?\n/);
let updated: string | null = null;
const tasks: ParsedTask[] = [];
let current: ParsedTask | null = null;
for (const line of lines) {
if (!updated) {
const m = line.match(UPDATED_FIELD);
if (m) updated = m[1];
}
const head = line.match(TASK_HEADER);
if (head) {
if (current) tasks.push(current);
const [, emoji, slug, description] = head;
current = {
slug,
status: EMOJI_TO_STATUS[emoji] ?? 'ready',
description: description.trim(),
next: null,
};
continue;
}
if (current) {
const m = line.match(NEXT_FIELD);
if (m) current.next = m[1].trim();
}
}
if (current) tasks.push(current);
return { updated, tasks };
}
- Step 4: Run, expect PASS
Run: npm test -- tests/lib/status-md.test.ts
Expected: 6 passed.
- Step 5: Commit
git add src/lib/status-md.ts tests/lib/status-md.test.ts
git commit -m "feat(status-md): parser for task board format"
Task 2.4: Gitea API client
Files:
- Create:
src/lib/gitea.ts - Create:
tests/lib/gitea.test.ts
Contract:
export interface GiteaRepo {
name: string;
default_branch: string;
}
export interface GiteaClient {
listUserRepos(user: string): Promise<GiteaRepo[]>;
getRawFile(user: string, repo: string, path: string, branch: string): Promise<string | null>;
// returns null on 404; throws on network/5xx/auth errors
}
export function makeGiteaClient(opts: {
baseUrl: string;
token: string;
fetchImpl?: typeof fetch;
}): GiteaClient;
- Step 1: Write failing tests
tests/lib/gitea.test.ts:
import { describe, it, expect } from 'vitest';
import { makeGiteaClient } from '../../src/lib/gitea.js';
function mockFetch(handler: (url: string, init?: RequestInit) => Response | Promise<Response>) {
return async (input: string | URL | Request, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input.toString();
return handler(url, init);
};
}
describe('GiteaClient', () => {
it('lists user repos with token in header', async () => {
let receivedAuth = '';
const fetchImpl = mockFetch((url, init) => {
receivedAuth = (init?.headers as Record<string, string>)['Authorization'];
expect(url).toBe('https://g/api/v1/users/u/repos?limit=50');
return new Response(
JSON.stringify([
{ name: 'r1', default_branch: 'main' },
{ name: 'r2', default_branch: 'master' },
]),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
});
const c = makeGiteaClient({ baseUrl: 'https://g', token: 't', fetchImpl: fetchImpl as never });
const repos = await c.listUserRepos('u');
expect(repos).toEqual([
{ name: 'r1', default_branch: 'main' },
{ name: 'r2', default_branch: 'master' },
]);
expect(receivedAuth).toBe('token t');
});
it('paginates listUserRepos', async () => {
const pages = [
[{ name: 'r1', default_branch: 'main' }, { name: 'r2', default_branch: 'main' }],
[{ name: 'r3', default_branch: 'main' }],
[],
];
let i = 0;
const fetchImpl = mockFetch(() => {
const body = JSON.stringify(pages[i++]);
return new Response(body, { status: 200, headers: { 'content-type': 'application/json' } });
});
const c = makeGiteaClient({ baseUrl: 'https://g', token: 't', fetchImpl: fetchImpl as never });
const repos = await c.listUserRepos('u');
expect(repos.map((r) => r.name)).toEqual(['r1', 'r2', 'r3']);
});
it('returns null on 404 raw file', async () => {
const fetchImpl = mockFetch(() => new Response('Not Found', { status: 404 }));
const c = makeGiteaClient({ baseUrl: 'https://g', token: 't', fetchImpl: fetchImpl as never });
const r = await c.getRawFile('u', 'repo', '.tasks/STATUS.md', 'main');
expect(r).toBeNull();
});
it('returns body on 200 raw file', async () => {
const fetchImpl = mockFetch((url) => {
expect(url).toBe('https://g/api/v1/repos/u/repo/raw/.tasks/STATUS.md?ref=main');
return new Response('# Task Board\n', { status: 200 });
});
const c = makeGiteaClient({ baseUrl: 'https://g', token: 't', fetchImpl: fetchImpl as never });
const r = await c.getRawFile('u', 'repo', '.tasks/STATUS.md', 'main');
expect(r).toBe('# Task Board\n');
});
it('throws on 500', async () => {
const fetchImpl = mockFetch(() => new Response('boom', { status: 500 }));
const c = makeGiteaClient({ baseUrl: 'https://g', token: 't', fetchImpl: fetchImpl as never });
await expect(c.getRawFile('u', 'r', 'p', 'main')).rejects.toThrow(/500/);
});
it('throws on 401', async () => {
const fetchImpl = mockFetch(() => new Response('nope', { status: 401 }));
const c = makeGiteaClient({ baseUrl: 'https://g', token: 't', fetchImpl: fetchImpl as never });
await expect(c.listUserRepos('u')).rejects.toThrow(/401/);
});
});
- Step 2: Run, expect FAIL
Run: npm test -- tests/lib/gitea.test.ts
Expected: FAIL — module missing.
- Step 3: Implement src/lib/gitea.ts
export interface GiteaRepo {
name: string;
default_branch: string;
}
export interface GiteaClient {
listUserRepos(user: string): Promise<GiteaRepo[]>;
getRawFile(
user: string,
repo: string,
path: string,
branch: string,
): Promise<string | null>;
}
interface Options {
baseUrl: string;
token: string;
fetchImpl?: typeof fetch;
}
const PAGE_SIZE = 50;
export function makeGiteaClient(opts: Options): GiteaClient {
const fetchImpl = opts.fetchImpl ?? fetch;
const baseUrl = opts.baseUrl.replace(/\/+$/, '');
const headers = { Authorization: `token ${opts.token}` };
async function call(url: string): Promise<Response> {
const res = await fetchImpl(url, { headers });
return res;
}
return {
async listUserRepos(user: string): Promise<GiteaRepo[]> {
const repos: GiteaRepo[] = [];
let page = 1;
while (true) {
const url =
`${baseUrl}/api/v1/users/${encodeURIComponent(user)}/repos?limit=${PAGE_SIZE}` +
(page > 1 ? `&page=${page}` : '');
const res = await call(url);
if (!res.ok) {
throw new Error(`Gitea listUserRepos ${res.status}: ${await res.text()}`);
}
const batch = (await res.json()) as Array<{ name: string; default_branch: string }>;
if (batch.length === 0) break;
for (const r of batch) repos.push({ name: r.name, default_branch: r.default_branch });
if (batch.length < PAGE_SIZE) break;
page += 1;
}
return repos;
},
async getRawFile(user, repo, path, branch) {
const url = `${baseUrl}/api/v1/repos/${encodeURIComponent(user)}/${encodeURIComponent(
repo,
)}/raw/${path}?ref=${encodeURIComponent(branch)}`;
const res = await call(url);
if (res.status === 404) return null;
if (!res.ok) {
throw new Error(`Gitea getRawFile ${res.status}: ${await res.text()}`);
}
return await res.text();
},
};
}
- Step 4: Run, expect PASS
Run: npm test -- tests/lib/gitea.test.ts
Expected: 6 passed.
- Step 5: Commit
git add src/lib/gitea.ts tests/lib/gitea.test.ts
git commit -m "feat(gitea): list-repos + raw-file client with pagination"
Task 2.5: Sync orchestrator
Files:
- Create:
src/lib/sync-runner.ts - Create:
tests/lib/sync-runner.test.ts
Contract:
export interface SyncDeps {
client: GiteaClient;
parseStatus: (raw: string) => ParsedStatus;
now: () => Date;
machine: string;
giteaUrl: string;
user: string;
concurrency?: number; // default 10
}
export async function runSync(deps: SyncDeps): Promise<CacheFile>;
- Step 1: Write failing tests
tests/lib/sync-runner.test.ts:
import { describe, it, expect } from 'vitest';
import { runSync } from '../../src/lib/sync-runner.js';
import { parseStatusMd } from '../../src/lib/status-md.js';
import type { GiteaClient } from '../../src/lib/gitea.js';
function fakeClient(repos: Array<{ name: string; default_branch: string }>, files: Record<string, string | null | Error>): GiteaClient {
return {
async listUserRepos() {
return repos;
},
async getRawFile(_u, repo) {
const v = files[repo];
if (v instanceof Error) throw v;
return v ?? null;
},
};
}
describe('runSync', () => {
const now = () => new Date('2026-04-29T12:00:00Z');
const sample = `# Task Board\n_Updated: 2026-04-29_\n\n## 🔴 [t1] — first\n**Next action:** do x\n`;
it('aggregates STATUS.md across repos', async () => {
const cache = await runSync({
client: fakeClient(
[{ name: 'a', default_branch: 'main' }, { name: 'b', default_branch: 'main' }],
{ a: sample, b: sample },
),
parseStatus: parseStatusMd,
now,
machine: 'm',
giteaUrl: 'https://g',
user: 'u',
});
expect(cache.projects).toHaveLength(2);
expect(cache.errors).toEqual([]);
expect(cache.projects[0].active_tasks[0].slug).toBe('t1');
});
it('skips 404 (no STATUS.md) without error', async () => {
const cache = await runSync({
client: fakeClient(
[{ name: 'a', default_branch: 'main' }, { name: 'no-tasks', default_branch: 'main' }],
{ a: sample, 'no-tasks': null },
),
parseStatus: parseStatusMd,
now,
machine: 'm',
giteaUrl: 'https://g',
user: 'u',
});
expect(cache.projects.map((p) => p.name)).toEqual(['a']);
expect(cache.errors).toEqual([]);
});
it('records error and continues on network failure', async () => {
const cache = await runSync({
client: fakeClient(
[{ name: 'a', default_branch: 'main' }, { name: 'broken', default_branch: 'main' }],
{ a: sample, broken: new Error('network down') },
),
parseStatus: parseStatusMd,
now,
machine: 'm',
giteaUrl: 'https://g',
user: 'u',
});
expect(cache.projects.map((p) => p.name)).toEqual(['a']);
expect(cache.errors).toEqual([{ project: 'broken', reason: 'network down' }]);
});
it('sets synced_at, machine, synced_from', async () => {
const cache = await runSync({
client: fakeClient([], {}),
parseStatus: parseStatusMd,
now,
machine: 'host-x',
giteaUrl: 'https://g',
user: 'u',
});
expect(cache.synced_at).toBe('2026-04-29T12:00:00.000Z');
expect(cache.machine).toBe('host-x');
expect(cache.synced_from).toBe('https://g');
});
});
- Step 2: Run, expect FAIL
Run: npm test -- tests/lib/sync-runner.test.ts
Expected: FAIL.
- Step 3: Implement src/lib/sync-runner.ts
import type { CacheFile, ProjectStatus, ActiveTask, SyncError } from './cache.js';
import type { GiteaClient } from './gitea.js';
import type { ParsedStatus, TaskStatus } from './status-md.js';
export interface SyncDeps {
client: GiteaClient;
parseStatus: (raw: string) => ParsedStatus;
now: () => Date;
machine: string;
giteaUrl: string;
user: string;
concurrency?: number;
}
const ACTIVE_STATES: TaskStatus[] = ['active', 'paused', 'blocked'];
async function pool<T, R>(items: T[], n: number, fn: (x: T) => Promise<R>): Promise<R[]> {
const out = new Array<R>(items.length);
let cursor = 0;
async function worker() {
while (true) {
const i = cursor++;
if (i >= items.length) return;
out[i] = await fn(items[i]);
}
}
await Promise.all(Array.from({ length: Math.min(n, items.length) }, worker));
return out;
}
export async function runSync(deps: SyncDeps): Promise<CacheFile> {
const concurrency = deps.concurrency ?? 10;
const repos = await deps.client.listUserRepos(deps.user);
const projects: ProjectStatus[] = [];
const errors: SyncError[] = [];
type Outcome =
| { kind: 'project'; data: ProjectStatus }
| { kind: 'skip' }
| { kind: 'error'; data: SyncError };
const results = await pool<typeof repos[number], Outcome>(repos, concurrency, async (repo) => {
try {
const raw = await deps.client.getRawFile(deps.user, repo.name, '.tasks/STATUS.md', repo.default_branch);
if (raw === null) return { kind: 'skip' as const };
const parsed = deps.parseStatus(raw);
const active: ActiveTask[] = parsed.tasks
.filter((t) => ACTIVE_STATES.includes(t.status))
.map((t) => ({ slug: t.slug, status: t.status, next: t.next }));
return {
kind: 'project' as const,
data: {
name: repo.name,
default_branch: repo.default_branch,
fetched_at: deps.now().toISOString(),
active_tasks: active,
all_tasks_count: parsed.tasks.length,
raw,
},
};
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
return { kind: 'error' as const, data: { project: repo.name, reason } };
}
});
for (const r of results) {
if (r.kind === 'project') projects.push(r.data);
else if (r.kind === 'error') errors.push(r.data);
}
return {
synced_at: deps.now().toISOString(),
synced_from: deps.giteaUrl,
machine: deps.machine,
projects,
errors,
};
}
- Step 4: Run, expect PASS
Run: npm test -- tests/lib/sync-runner.test.ts
Expected: 4 passed.
- Step 5: Commit
git add src/lib/sync-runner.ts tests/lib/sync-runner.test.ts
git commit -m "feat(sync): orchestrator with concurrency, 404-skip, error-collect"
Task 2.6: Sync CLI entry
Files:
-
Modify:
src/sync.ts(replace skeleton) -
Step 1: Implement src/sync.ts
import { homedir, hostname } from 'node:os';
import { appendFile, mkdir } from 'node:fs/promises';
import { dirname } from 'node:path';
import { writeCache } from './lib/cache.js';
import { getPaths, loadAuth } from './lib/config.js';
import { makeGiteaClient } from './lib/gitea.js';
import { parseStatusMd } from './lib/status-md.js';
import { runSync } from './lib/sync-runner.js';
async function main(): Promise<void> {
const paths = getPaths(homedir());
const auth = await loadAuth(paths.authFile).catch((err) => {
console.error(`auth.toml load failed: ${(err as Error).message}`);
console.error(`Expected at: ${paths.authFile}`);
process.exit(2);
});
const client = makeGiteaClient({ baseUrl: auth.giteaUrl, token: auth.giteaToken });
const cache = await runSync({
client,
parseStatus: parseStatusMd,
now: () => new Date(),
machine: hostname(),
giteaUrl: auth.giteaUrl,
user: auth.giteaUser,
});
await writeCache(paths.cacheFile, cache);
await mkdir(dirname(paths.syncLog), { recursive: true });
const line =
`[${cache.synced_at}] projects=${cache.projects.length} errors=${cache.errors.length}` +
(cache.errors.length ? ` (${cache.errors.map((e) => e.project).join(',')})` : '') +
'\n';
await appendFile(paths.syncLog, line);
console.log(`synced ${cache.projects.length} projects, ${cache.errors.length} errors`);
}
await main();
- Step 2: Build + verify it compiles
Run: npm run build
Expected: no errors. dist/sync.js exists.
- Step 3: Run typecheck
Run: npm run typecheck
Expected: no errors.
- Step 4: Commit
git add src/sync.ts
git commit -m "feat(sync): CLI entry wiring config + runner + cache + log"
Phase 3 — MCP server core
Task 3.1: Server bootstrap (stdio + tool registry)
Files:
- Modify:
src/server.ts(replace skeleton)
The MCP SDK exposes Server and StdioServerTransport. Tools are registered in handlers. The server reads process.argv[2] if given as --cwd <path> for explicit cwd-override; otherwise uses process.cwd().
- Step 1: Replace src/server.ts
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
import { homedir } from 'node:os';
import { getPaths } from './lib/config.js';
import { makeTasksTools } from './tools/tasks.js';
import { makeKnowledgeTools } from './tools/knowledge.js';
import { makeMetaTools } from './tools/meta.js';
function getCwdArg(): string {
const i = process.argv.indexOf('--cwd');
if (i >= 0 && process.argv[i + 1]) return process.argv[i + 1];
return process.cwd();
}
async function main(): Promise<void> {
const paths = getPaths(homedir());
const cwd = getCwdArg();
const tools = [
...makeTasksTools({ cacheFile: paths.cacheFile }),
...makeKnowledgeTools({ wikiRoot: paths.sharedWikiClone, projectCwd: cwd }),
...makeMetaTools({ paths }),
];
const byName = new Map(tools.map((t) => [t.name, t]));
const server = new Server(
{ name: 'projects-meta-mcp', version: '0.1.0' },
{ capabilities: { tools: {} } },
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: tools.map((t) => ({
name: t.name,
description: t.description,
inputSchema: t.inputSchema,
})),
}));
server.setRequestHandler(CallToolRequestSchema, async (req) => {
const tool = byName.get(req.params.name);
if (!tool) {
return {
content: [{ type: 'text', text: `Unknown tool: ${req.params.name}` }],
isError: true,
};
}
return await tool.handler(req.params.arguments ?? {});
});
const transport = new StdioServerTransport();
await server.connect(transport);
}
await main();
- Step 2: Define ToolDef shape
Create src/tools/types.ts:
export interface ToolResult {
content: Array<{ type: 'text'; text: string }>;
isError?: boolean;
}
export interface ToolDef {
name: string;
description: string;
inputSchema: {
type: 'object';
properties?: Record<string, unknown>;
required?: string[];
additionalProperties?: boolean;
};
handler: (args: Record<string, unknown>) => Promise<ToolResult>;
}
- Step 3: Stub the three tool factories so build passes
Create empty stubs (real impl in later tasks). Each returns [].
src/tools/tasks.ts:
import type { ToolDef } from './types.js';
export function makeTasksTools(_opts: { cacheFile: string }): ToolDef[] {
return [];
}
src/tools/knowledge.ts:
import type { ToolDef } from './types.js';
export function makeKnowledgeTools(_opts: {
wikiRoot: string;
projectCwd: string;
}): ToolDef[] {
return [];
}
src/tools/meta.ts:
import type { ToolDef } from './types.js';
import type { Paths } from '../lib/config.js';
export function makeMetaTools(_opts: { paths: Paths }): ToolDef[] {
return [];
}
- Step 4: Build
Run: npm run build
Expected: no errors.
- Step 5: Commit
git add src/server.ts src/tools/types.ts src/tools/tasks.ts src/tools/knowledge.ts src/tools/meta.ts
git commit -m "feat(server): stdio bootstrap + tool registry skeleton"
Task 3.2: Tasks tools (tasks.aggregate, tasks.search, tasks.get)
Files:
- Modify:
src/tools/tasks.ts - Create:
tests/tools/tasks.test.ts
Contracts:
-
tasks.aggregate({ status?, project? })→ list of{ project, slug, status, description, next }filtered by both. No raw text. -
tasks.search({ query })→ matchesquery(case-insensitive substring) againstslug + description + next. Returns same shape as aggregate. -
tasks.get({ project })→ returns full STATUS.md raw of one project. -
Step 1: Write failing tests
tests/tools/tasks.test.ts:
import { describe, it, expect, beforeEach } from 'vitest';
import { mkdtemp } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { writeCache, type CacheFile } from '../../src/lib/cache.js';
import { makeTasksTools } from '../../src/tools/tasks.js';
const fixture: CacheFile = {
synced_at: '2026-04-29T12:00:00.000Z',
synced_from: 'https://g',
machine: 'm',
projects: [
{
name: 'alpha',
default_branch: 'main',
fetched_at: '2026-04-29T12:00:01.000Z',
active_tasks: [
{ slug: 'auth-rewrite', status: 'active', next: 'add tests' },
{ slug: 'docs', status: 'paused', next: 'screencast' },
],
all_tasks_count: 2,
raw: '# Task Board\n## 🔴 [auth-rewrite] — Auth\n',
},
{
name: 'beta',
default_branch: 'main',
fetched_at: '2026-04-29T12:00:02.000Z',
active_tasks: [{ slug: 'login', status: 'active', next: 'fix bug' }],
all_tasks_count: 1,
raw: '# Task Board\n## 🔴 [login] — Login\n',
},
],
errors: [],
};
let cacheFile: string;
beforeEach(async () => {
const dir = await mkdtemp(join(tmpdir(), 'tasks-tools-'));
cacheFile = join(dir, 'tasks.json');
await writeCache(cacheFile, fixture);
});
function call(tools: ReturnType<typeof makeTasksTools>, name: string, args: Record<string, unknown>) {
const t = tools.find((x) => x.name === name);
if (!t) throw new Error(`tool ${name} not registered`);
return t.handler(args);
}
describe('tasks.aggregate', () => {
it('lists everything by default', async () => {
const tools = makeTasksTools({ cacheFile });
const r = await call(tools, 'tasks.aggregate', {});
const parsed = JSON.parse(r.content[0].text);
expect(parsed.tasks).toHaveLength(3);
expect(parsed.tasks[0]).toEqual({
project: 'alpha',
slug: 'auth-rewrite',
status: 'active',
next: 'add tests',
});
});
it('filters by status', async () => {
const tools = makeTasksTools({ cacheFile });
const r = await call(tools, 'tasks.aggregate', { status: 'active' });
const parsed = JSON.parse(r.content[0].text);
expect(parsed.tasks.map((t: { slug: string }) => t.slug)).toEqual(['auth-rewrite', 'login']);
});
it('filters by project', async () => {
const tools = makeTasksTools({ cacheFile });
const r = await call(tools, 'tasks.aggregate', { project: 'beta' });
const parsed = JSON.parse(r.content[0].text);
expect(parsed.tasks.map((t: { slug: string }) => t.slug)).toEqual(['login']);
});
it('returns empty when cache missing', async () => {
const tools = makeTasksTools({ cacheFile: '/nonexistent/tasks.json' });
const r = await call(tools, 'tasks.aggregate', {});
const parsed = JSON.parse(r.content[0].text);
expect(parsed.tasks).toEqual([]);
expect(parsed.cache_missing).toBe(true);
});
});
describe('tasks.search', () => {
it('matches case-insensitive substring on slug/next', async () => {
const tools = makeTasksTools({ cacheFile });
const r = await call(tools, 'tasks.search', { query: 'AUTH' });
const parsed = JSON.parse(r.content[0].text);
expect(parsed.tasks.map((t: { slug: string }) => t.slug)).toEqual(['auth-rewrite']);
});
it('matches on next-action text', async () => {
const tools = makeTasksTools({ cacheFile });
const r = await call(tools, 'tasks.search', { query: 'screencast' });
const parsed = JSON.parse(r.content[0].text);
expect(parsed.tasks.map((t: { slug: string }) => t.slug)).toEqual(['docs']);
});
});
describe('tasks.get', () => {
it('returns full raw STATUS.md', async () => {
const tools = makeTasksTools({ cacheFile });
const r = await call(tools, 'tasks.get', { project: 'alpha' });
expect(r.content[0].text).toContain('[auth-rewrite]');
});
it('returns error for unknown project', async () => {
const tools = makeTasksTools({ cacheFile });
const r = await call(tools, 'tasks.get', { project: 'ghost' });
expect(r.isError).toBe(true);
});
});
- Step 2: Run, expect FAIL
Run: npm test -- tests/tools/tasks.test.ts
Expected: FAIL.
- Step 3: Implement src/tools/tasks.ts
import { z } from 'zod';
import { readCache } from '../lib/cache.js';
import type { ToolDef, ToolResult } from './types.js';
interface Opts {
cacheFile: string;
}
const StatusEnum = z.enum(['active', 'paused', 'blocked', 'ready', 'done']);
const AggregateInput = z.object({
status: StatusEnum.optional(),
project: z.string().optional(),
});
const SearchInput = z.object({
query: z.string().min(1),
});
const GetInput = z.object({
project: z.string().min(1),
});
function asJson(value: unknown): ToolResult {
return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] };
}
function asError(msg: string): ToolResult {
return { content: [{ type: 'text', text: msg }], isError: true };
}
export function makeTasksTools(opts: Opts): ToolDef[] {
return [
{
name: 'tasks.aggregate',
description:
'Свод активных задач (active/paused/blocked) по всем проектам. Фильтры по статусу и имени проекта. Не возвращает полный текст STATUS.md.',
inputSchema: {
type: 'object',
properties: {
status: { type: 'string', enum: ['active', 'paused', 'blocked', 'ready', 'done'] },
project: { type: 'string' },
},
additionalProperties: false,
},
async handler(args) {
const input = AggregateInput.parse(args);
const cache = await readCache(opts.cacheFile);
if (!cache) return asJson({ tasks: [], cache_missing: true });
const out: Array<{ project: string; slug: string; status: string; next: string | null }> = [];
for (const p of cache.projects) {
if (input.project && p.name !== input.project) continue;
for (const t of p.active_tasks) {
if (input.status && t.status !== input.status) continue;
out.push({ project: p.name, slug: t.slug, status: t.status, next: t.next });
}
}
return asJson({ tasks: out, synced_at: cache.synced_at });
},
},
{
name: 'tasks.search',
description:
'Подстрочный регистронезависимый поиск по slug / next-action всех задач в кэше. Возвращает то же что tasks.aggregate.',
inputSchema: {
type: 'object',
properties: { query: { type: 'string' } },
required: ['query'],
additionalProperties: false,
},
async handler(args) {
const input = SearchInput.parse(args);
const cache = await readCache(opts.cacheFile);
if (!cache) return asJson({ tasks: [], cache_missing: true });
const q = input.query.toLowerCase();
const out: Array<{ project: string; slug: string; status: string; next: string | null }> = [];
for (const p of cache.projects) {
for (const t of p.active_tasks) {
const hay = `${t.slug} ${t.next ?? ''}`.toLowerCase();
if (hay.includes(q)) {
out.push({ project: p.name, slug: t.slug, status: t.status, next: t.next });
}
}
}
return asJson({ tasks: out, synced_at: cache.synced_at });
},
},
{
name: 'tasks.get',
description: 'Полный raw STATUS.md одного проекта (как было получено при последнем sync).',
inputSchema: {
type: 'object',
properties: { project: { type: 'string' } },
required: ['project'],
additionalProperties: false,
},
async handler(args) {
const input = GetInput.parse(args);
const cache = await readCache(opts.cacheFile);
if (!cache) return asError('cache missing — run sync first');
const p = cache.projects.find((x) => x.name === input.project);
if (!p) return asError(`project not found: ${input.project}`);
return { content: [{ type: 'text', text: p.raw }] };
},
},
];
}
- Step 4: Run, expect PASS
Run: npm test -- tests/tools/tasks.test.ts
Expected: 8 passed.
- Step 5: Commit
git add src/tools/tasks.ts tests/tools/tasks.test.ts
git commit -m "feat(tools): tasks.aggregate/search/get"
Task 3.3: Domain detector
Files:
- Create:
src/lib/domain-detector.ts - Create:
tests/lib/domain-detector.test.ts
Contract:
export type Domain = 'node' | 'embedded' | 'web' | 'unknown';
export async function detectDomain(cwd: string): Promise<Domain>;
Decision rules (spec §detect domain):
package.jsonexists →nodeplatformio.iniOR any*.inoORCMakeLists.txtcontainingarm-none-eabiorSTM32orESP_IDF→embeddednext.config.*ORvite.config.*OR (index.htmlAND nopackage.json) →web- else →
unknown
- Step 1: Write failing tests
tests/lib/domain-detector.test.ts:
import { describe, it, expect } from 'vitest';
import { mkdtemp, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { detectDomain } from '../../src/lib/domain-detector.js';
async function tmp(): Promise<string> {
return mkdtemp(join(tmpdir(), 'dd-'));
}
describe('detectDomain', () => {
it('returns node when package.json present', async () => {
const d = await tmp();
await writeFile(join(d, 'package.json'), '{}');
expect(await detectDomain(d)).toBe('node');
});
it('returns embedded for platformio.ini', async () => {
const d = await tmp();
await writeFile(join(d, 'platformio.ini'), '[env]');
expect(await detectDomain(d)).toBe('embedded');
});
it('returns embedded for .ino', async () => {
const d = await tmp();
await writeFile(join(d, 'sketch.ino'), 'void setup() {}');
expect(await detectDomain(d)).toBe('embedded');
});
it('returns embedded for ARM CMakeLists', async () => {
const d = await tmp();
await writeFile(join(d, 'CMakeLists.txt'), 'set(CMAKE_C_COMPILER arm-none-eabi-gcc)');
expect(await detectDomain(d)).toBe('embedded');
});
it('returns web for vite config', async () => {
const d = await tmp();
await writeFile(join(d, 'vite.config.ts'), 'export default {}');
expect(await detectDomain(d)).toBe('web');
});
it('returns web for static index.html without package.json', async () => {
const d = await tmp();
await writeFile(join(d, 'index.html'), '<html></html>');
expect(await detectDomain(d)).toBe('web');
});
it('returns unknown for empty dir', async () => {
const d = await tmp();
expect(await detectDomain(d)).toBe('unknown');
});
it('package.json wins over index.html', async () => {
const d = await tmp();
await writeFile(join(d, 'package.json'), '{}');
await writeFile(join(d, 'index.html'), '<html></html>');
expect(await detectDomain(d)).toBe('node');
});
});
- Step 2: Run, expect FAIL
Run: npm test -- tests/lib/domain-detector.test.ts
Expected: FAIL.
- Step 3: Implement src/lib/domain-detector.ts
import { readdir, readFile, stat } from 'node:fs/promises';
import { join } from 'node:path';
export type Domain = 'node' | 'embedded' | 'web' | 'unknown';
const EMBEDDED_NEEDLES = ['arm-none-eabi', 'STM32', 'ESP_IDF', 'STM32CubeMX'];
async function exists(p: string): Promise<boolean> {
try {
await stat(p);
return true;
} catch {
return false;
}
}
async function listFiles(dir: string): Promise<string[]> {
try {
const entries = await readdir(dir, { withFileTypes: true });
return entries.filter((e) => e.isFile()).map((e) => e.name);
} catch {
return [];
}
}
export async function detectDomain(cwd: string): Promise<Domain> {
const pkgJson = await exists(join(cwd, 'package.json'));
if (pkgJson) return 'node';
if (await exists(join(cwd, 'platformio.ini'))) return 'embedded';
const files = await listFiles(cwd);
if (files.some((f) => f.endsWith('.ino'))) return 'embedded';
if (files.includes('CMakeLists.txt')) {
const txt = await readFile(join(cwd, 'CMakeLists.txt'), 'utf8').catch(() => '');
if (EMBEDDED_NEEDLES.some((n) => txt.includes(n))) return 'embedded';
}
if (files.some((f) => /^next\.config\.(js|mjs|cjs|ts)$/.test(f))) return 'web';
if (files.some((f) => /^vite\.config\.(js|mjs|cjs|ts)$/.test(f))) return 'web';
if (files.includes('index.html')) return 'web';
return 'unknown';
}
- Step 4: Run, expect PASS
Run: npm test -- tests/lib/domain-detector.test.ts
Expected: 8 passed.
- Step 5: Commit
git add src/lib/domain-detector.ts tests/lib/domain-detector.test.ts
git commit -m "feat(domain): node/embedded/web detector by cwd files"
Task 3.4: Knowledge tools (knowledge.search, knowledge.get)
Files:
- Create:
src/lib/wiki-index.ts - Modify:
src/tools/knowledge.ts - Create:
tests/lib/wiki-index.test.ts - Create:
tests/tools/knowledge.test.ts
Contract for wiki-index:
export interface WikiPage {
slug: string; // path relative to wikiRoot, no .md extension, e.g. "node/windows-yarn"
title: string;
domain: string; // from frontmatter
tags: string[];
body: string; // body text only (no frontmatter)
raw: string; // full file contents
}
export async function loadWiki(root: string): Promise<WikiPage[]>;
Walk all *.md files except CLAUDE.md and README.md at root level (those are wiki meta). Skip pages without domain: frontmatter (warn but include with domain="unknown").
Contract for knowledge tools:
-
knowledge.search({ query, domain?, limit? })→ list of{ slug, title, snippet, domain }. Defaultlimit=10. Default domain filter = detected from cwd; if cwd domain isunknownno filter; ifdomain="all"no filter; otherwise show pages wheredomain == filter || domain == "cross". -
knowledge.get({ slug })→ full page contents. -
Step 1: Write failing wiki-index tests
tests/lib/wiki-index.test.ts:
import { describe, it, expect, beforeEach } from 'vitest';
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { loadWiki } from '../../src/lib/wiki-index.js';
let root: string;
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'wiki-'));
await mkdir(join(root, 'node'), { recursive: true });
await mkdir(join(root, 'cross'), { recursive: true });
await writeFile(join(root, 'CLAUDE.md'), 'should be skipped');
await writeFile(join(root, 'README.md'), 'should be skipped');
await writeFile(
join(root, 'node', 'windows-yarn-exec.md'),
[
'---',
'title: Windows yarn requires exec()',
'domain: node',
'tags: [windows, yarn]',
'---',
'',
'## Why',
'Windows is special.',
].join('\n'),
);
await writeFile(
join(root, 'cross', 'git-tips.md'),
['---', 'title: Git tips', 'domain: cross', 'tags: [git]', '---', '', 'body'].join('\n'),
);
await writeFile(
join(root, 'cross', 'no-fm.md'),
'just a body, no frontmatter',
);
});
describe('loadWiki', () => {
it('skips top-level meta files', async () => {
const pages = await loadWiki(root);
expect(pages.find((p) => p.slug === 'CLAUDE')).toBeUndefined();
expect(pages.find((p) => p.slug === 'README')).toBeUndefined();
});
it('loads pages with frontmatter', async () => {
const pages = await loadWiki(root);
const yarn = pages.find((p) => p.slug === 'node/windows-yarn-exec');
expect(yarn).toBeDefined();
expect(yarn!.title).toBe('Windows yarn requires exec()');
expect(yarn!.domain).toBe('node');
expect(yarn!.tags).toEqual(['windows', 'yarn']);
expect(yarn!.body).toContain('Windows is special.');
});
it('marks frontmatterless pages as unknown domain', async () => {
const pages = await loadWiki(root);
const fm = pages.find((p) => p.slug === 'cross/no-fm');
expect(fm).toBeDefined();
expect(fm!.domain).toBe('unknown');
});
it('returns empty array if root missing', async () => {
const pages = await loadWiki(join(root, 'does-not-exist'));
expect(pages).toEqual([]);
});
});
- Step 2: Run, expect FAIL
Run: npm test -- tests/lib/wiki-index.test.ts
Expected: FAIL.
- Step 3: Implement src/lib/wiki-index.ts
import { readdir, readFile, stat } from 'node:fs/promises';
import { join, relative, sep } from 'node:path';
import matter from 'gray-matter';
export interface WikiPage {
slug: string;
title: string;
domain: string;
tags: string[];
body: string;
raw: string;
}
const META_TOP = new Set(['CLAUDE.md', 'README.md', 'index.md', 'log.md', 'overview.md']);
async function walk(root: string, prefix = ''): Promise<string[]> {
const out: string[] = [];
let entries;
try {
entries = await readdir(root, { withFileTypes: true });
} catch {
return [];
}
for (const e of entries) {
const rel = prefix ? `${prefix}/${e.name}` : e.name;
if (e.isDirectory()) {
if (e.name.startsWith('.')) continue;
const nested = await walk(join(root, e.name), rel);
out.push(...nested);
} else if (e.isFile() && e.name.endsWith('.md')) {
if (prefix === '' && META_TOP.has(e.name)) continue;
out.push(rel);
}
}
return out;
}
export async function loadWiki(root: string): Promise<WikiPage[]> {
try {
const s = await stat(root);
if (!s.isDirectory()) return [];
} catch {
return [];
}
const relPaths = await walk(root);
const pages: WikiPage[] = [];
for (const rel of relPaths) {
const abs = join(root, ...rel.split('/'));
const raw = await readFile(abs, 'utf8');
const slug = rel.replace(/\.md$/, '').split(sep).join('/');
const parsed = matter(raw);
const fm = parsed.data as Record<string, unknown>;
pages.push({
slug,
title: typeof fm.title === 'string' ? fm.title : slug,
domain: typeof fm.domain === 'string' ? fm.domain : 'unknown',
tags: Array.isArray(fm.tags) ? (fm.tags as unknown[]).filter((t): t is string => typeof t === 'string') : [],
body: parsed.content,
raw,
});
}
return pages;
}
- Step 4: Run wiki-index tests, expect PASS
Run: npm test -- tests/lib/wiki-index.test.ts
Expected: 4 passed.
- Step 5: Commit wiki-index
git add src/lib/wiki-index.ts tests/lib/wiki-index.test.ts
git commit -m "feat(wiki): page loader with frontmatter parse"
- Step 6: Write failing knowledge-tools tests
tests/tools/knowledge.test.ts:
import { describe, it, expect, beforeEach } from 'vitest';
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { makeKnowledgeTools } from '../../src/tools/knowledge.js';
let wikiRoot: string;
let nodeProject: string;
let unknownProject: string;
beforeEach(async () => {
wikiRoot = await mkdtemp(join(tmpdir(), 'kw-wiki-'));
await mkdir(join(wikiRoot, 'node'), { recursive: true });
await mkdir(join(wikiRoot, 'embedded'), { recursive: true });
await mkdir(join(wikiRoot, 'cross'), { recursive: true });
await writeFile(
join(wikiRoot, 'node', 'yarn-windows.md'),
'---\ntitle: Yarn on Windows\ndomain: node\n---\n\nyarn body',
);
await writeFile(
join(wikiRoot, 'embedded', 'stm32-flash.md'),
'---\ntitle: STM32 Flashing\ndomain: embedded\n---\n\nstm32 body',
);
await writeFile(
join(wikiRoot, 'cross', 'git-trick.md'),
'---\ntitle: Git Trick\ndomain: cross\n---\n\ngit yarn body',
);
nodeProject = await mkdtemp(join(tmpdir(), 'kw-proj-'));
await writeFile(join(nodeProject, 'package.json'), '{}');
unknownProject = await mkdtemp(join(tmpdir(), 'kw-proj-'));
});
function call(tools: ReturnType<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.search (node project)', () => {
it('shows node + cross by default, hides embedded', async () => {
const tools = makeKnowledgeTools({ wikiRoot, projectCwd: nodeProject });
const r = await call(tools, 'knowledge.search', { query: 'yarn' });
const parsed = JSON.parse(r.content[0].text);
const slugs = parsed.results.map((x: { slug: string }) => x.slug).sort();
expect(slugs).toEqual(['cross/git-trick', 'node/yarn-windows']);
});
it('domain="all" shows everything', async () => {
const tools = makeKnowledgeTools({ wikiRoot, projectCwd: nodeProject });
const r = await call(tools, 'knowledge.search', { query: '', domain: 'all' });
const parsed = JSON.parse(r.content[0].text);
expect(parsed.results.length).toBe(3);
});
it('explicit domain switches filter', async () => {
const tools = makeKnowledgeTools({ wikiRoot, projectCwd: nodeProject });
const r = await call(tools, 'knowledge.search', { query: '', domain: 'embedded' });
const parsed = JSON.parse(r.content[0].text);
const slugs = parsed.results.map((x: { slug: string }) => x.slug).sort();
expect(slugs).toEqual(['cross/git-trick', 'embedded/stm32-flash']);
});
});
describe('knowledge.search (unknown project)', () => {
it('returns all when cwd domain unknown', async () => {
const tools = makeKnowledgeTools({ wikiRoot, projectCwd: unknownProject });
const r = await call(tools, 'knowledge.search', { query: '' });
const parsed = JSON.parse(r.content[0].text);
expect(parsed.results.length).toBe(3);
});
});
describe('knowledge.get', () => {
it('returns full page including frontmatter', async () => {
const tools = makeKnowledgeTools({ wikiRoot, projectCwd: nodeProject });
const r = await call(tools, 'knowledge.get', { slug: 'node/yarn-windows' });
expect(r.content[0].text).toContain('domain: node');
expect(r.content[0].text).toContain('yarn body');
});
it('errors on missing slug', async () => {
const tools = makeKnowledgeTools({ wikiRoot, projectCwd: nodeProject });
const r = await call(tools, 'knowledge.get', { slug: 'ghost/page' });
expect(r.isError).toBe(true);
});
});
- Step 7: Run, expect FAIL
Run: npm test -- tests/tools/knowledge.test.ts
Expected: FAIL.
- Step 8: Implement src/tools/knowledge.ts
import { z } from 'zod';
import { detectDomain, type Domain } from '../lib/domain-detector.js';
import { loadWiki, type WikiPage } from '../lib/wiki-index.js';
import type { ToolDef, ToolResult } from './types.js';
interface Opts {
wikiRoot: string;
projectCwd: string;
}
const SearchInput = z.object({
query: z.string(),
domain: z.string().optional(),
limit: z.number().int().positive().max(50).optional(),
});
const GetInput = z.object({
slug: z.string().min(1),
});
function asJson(value: unknown): ToolResult {
return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] };
}
function asError(msg: string): ToolResult {
return { content: [{ type: 'text', text: msg }], isError: true };
}
function snippet(body: string, query: string, span = 80): string {
const trimmed = body.replace(/\s+/g, ' ').trim();
if (!query) return trimmed.slice(0, span);
const idx = trimmed.toLowerCase().indexOf(query.toLowerCase());
if (idx < 0) return trimmed.slice(0, span);
const start = Math.max(0, idx - 20);
return trimmed.slice(start, start + span);
}
function pageMatches(p: WikiPage, query: string): boolean {
if (!query) return true;
const q = query.toLowerCase();
return (
p.title.toLowerCase().includes(q) ||
p.body.toLowerCase().includes(q) ||
p.tags.some((t) => t.toLowerCase().includes(q))
);
}
function domainAllows(pageDomain: string, filter: string): boolean {
if (filter === 'all') return true;
return pageDomain === filter || pageDomain === 'cross';
}
export function makeKnowledgeTools(opts: Opts): ToolDef[] {
let cached: WikiPage[] | null = null;
async function pages(): Promise<WikiPage[]> {
if (cached) return cached;
cached = await loadWiki(opts.wikiRoot);
return cached;
}
let detectedDomain: Domain | null = null;
async function domain(): Promise<Domain> {
if (detectedDomain) return detectedDomain;
detectedDomain = await detectDomain(opts.projectCwd);
return detectedDomain;
}
return [
{
name: 'knowledge.search',
description:
'Поиск по shared-вики (репо projects-wiki). По умолчанию фильтр по domain соответствует cwd текущего проекта (node/embedded/web). Передай domain="all" чтобы снять фильтр или явное имя домена. limit по умолчанию 10. Возвращает только заголовок + сниппет; полный текст — knowledge.get.',
inputSchema: {
type: 'object',
properties: {
query: { type: 'string' },
domain: { type: 'string' },
limit: { type: 'integer', minimum: 1, maximum: 50 },
},
required: ['query'],
additionalProperties: false,
},
async handler(args) {
const input = SearchInput.parse(args);
const all = await pages();
const det = await domain();
const filter = input.domain ?? (det === 'unknown' ? 'all' : det);
const limit = input.limit ?? 10;
const matched = all
.filter((p) => domainAllows(p.domain, filter))
.filter((p) => pageMatches(p, input.query))
.slice(0, limit)
.map((p) => ({
slug: p.slug,
title: p.title,
domain: p.domain,
snippet: snippet(p.body, input.query),
}));
return asJson({
detected_domain: det,
applied_filter: filter,
results: matched,
});
},
},
{
name: 'knowledge.get',
description: 'Полный текст одной страницы projects-wiki по её slug (например "node/windows-yarn-exec").',
inputSchema: {
type: 'object',
properties: { slug: { type: 'string' } },
required: ['slug'],
additionalProperties: false,
},
async handler(args) {
const input = GetInput.parse(args);
const all = await pages();
const page = all.find((p) => p.slug === input.slug);
if (!page) return asError(`page not found: ${input.slug}`);
return { content: [{ type: 'text', text: page.raw }] };
},
},
];
}
- Step 9: Run, expect PASS
Run: npm test -- tests/tools/knowledge.test.ts
Expected: 6 passed.
- Step 10: Commit
git add src/tools/knowledge.ts tests/tools/knowledge.test.ts
git commit -m "feat(tools): knowledge.search/get with domain filter"
Task 3.5: Meta tools (meta.status)
Files:
- Modify:
src/tools/meta.ts - Create:
tests/tools/meta.test.ts
Contract:
meta.status() → { synced_at, age_seconds, projects_count, errors_count, cache_path, wiki_root, wiki_pages_count, stale: boolean }
Stale threshold: 24 hours (age_seconds > 86400).
- Step 1: Write failing tests
tests/tools/meta.test.ts:
import { describe, it, expect, beforeEach } from 'vitest';
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { writeCache } from '../../src/lib/cache.js';
import { makeMetaTools } from '../../src/tools/meta.js';
import type { Paths } from '../../src/lib/config.js';
function mkPaths(home: string): Paths {
const cacheDir = join(home, '.cache');
return {
cacheDir,
cacheFile: join(cacheDir, 'tasks.json'),
syncLog: join(cacheDir, 'sync.log'),
authFile: join(home, '.config', 'auth.toml'),
sharedWikiClone: join(home, 'wiki'),
};
}
describe('meta.status', () => {
let paths: Paths;
beforeEach(async () => {
const home = await mkdtemp(join(tmpdir(), 'meta-'));
paths = mkPaths(home);
await mkdir(paths.sharedWikiClone, { recursive: true });
await mkdir(join(paths.sharedWikiClone, 'node'), { recursive: true });
await writeFile(
join(paths.sharedWikiClone, 'node', 'one.md'),
'---\ntitle: t\ndomain: node\n---\n\nbody',
);
});
it('reports cache_missing when no cache', async () => {
const tools = makeMetaTools({ paths });
const r = await (tools.find((t) => t.name === 'meta.status')!.handler({}));
const parsed = JSON.parse(r.content[0].text);
expect(parsed.cache_missing).toBe(true);
expect(parsed.wiki_pages_count).toBe(1);
});
it('reports age and stale=false for fresh cache', async () => {
await writeCache(paths.cacheFile, {
synced_at: new Date().toISOString(),
synced_from: 'https://g',
machine: 'm',
projects: [],
errors: [],
});
const tools = makeMetaTools({ paths });
const r = await (tools.find((t) => t.name === 'meta.status')!.handler({}));
const parsed = JSON.parse(r.content[0].text);
expect(parsed.stale).toBe(false);
expect(parsed.age_seconds).toBeGreaterThanOrEqual(0);
});
it('reports stale=true for >24h cache', async () => {
const old = new Date(Date.now() - 25 * 3600 * 1000).toISOString();
await writeCache(paths.cacheFile, {
synced_at: old,
synced_from: 'https://g',
machine: 'm',
projects: [{ name: 'p', default_branch: 'main', fetched_at: old, active_tasks: [], all_tasks_count: 0, raw: '' }],
errors: [{ project: 'q', reason: 'x' }],
});
const tools = makeMetaTools({ paths });
const r = await (tools.find((t) => t.name === 'meta.status')!.handler({}));
const parsed = JSON.parse(r.content[0].text);
expect(parsed.stale).toBe(true);
expect(parsed.projects_count).toBe(1);
expect(parsed.errors_count).toBe(1);
});
});
- Step 2: Run, expect FAIL
Run: npm test -- tests/tools/meta.test.ts
Expected: FAIL.
- Step 3: Implement src/tools/meta.ts
import { readCache } from '../lib/cache.js';
import type { Paths } from '../lib/config.js';
import { loadWiki } from '../lib/wiki-index.js';
import type { ToolDef, ToolResult } from './types.js';
const STALE_AFTER_SEC = 24 * 3600;
interface Opts {
paths: Paths;
}
function asJson(value: unknown): ToolResult {
return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] };
}
export function makeMetaTools(opts: Opts): ToolDef[] {
return [
{
name: 'meta.status',
description:
'Диагностика: возраст кэша, количество проектов / ошибок / страниц wiki. Используй когда нужно понять, свежие ли данные.',
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
async handler() {
const cache = await readCache(opts.paths.cacheFile);
const wiki = await loadWiki(opts.paths.sharedWikiClone);
if (!cache) {
return asJson({
cache_missing: true,
cache_path: opts.paths.cacheFile,
wiki_root: opts.paths.sharedWikiClone,
wiki_pages_count: wiki.length,
});
}
const ageMs = Date.now() - new Date(cache.synced_at).getTime();
const ageSec = Math.floor(ageMs / 1000);
return asJson({
synced_at: cache.synced_at,
age_seconds: ageSec,
stale: ageSec > STALE_AFTER_SEC,
projects_count: cache.projects.length,
errors_count: cache.errors.length,
cache_path: opts.paths.cacheFile,
wiki_root: opts.paths.sharedWikiClone,
wiki_pages_count: wiki.length,
});
},
},
];
}
- Step 4: Run, expect PASS
Run: npm test -- tests/tools/meta.test.ts
Expected: 3 passed.
- Step 5: Commit
git add src/tools/meta.ts tests/tools/meta.test.ts
git commit -m "feat(tools): meta.status with stale threshold"
Task 3.6: Promotion candidate tool (knowledge.suggest_promote)
Files:
- Create:
src/lib/promotion.ts - Create:
tests/lib/promotion.test.ts - Modify:
src/tools/knowledge.ts(add tool) - Modify:
tests/tools/knowledge.test.ts(extend tests)
Heuristic (per spec §knowledge.suggest_promote):
- INCLUDE if page mentions any platform/tooling keyword:
Windows,Linux,macOS,yarn,npm,Node,MCP,git,Docker,tsx,vite,webpack,pnpm. - EXCLUDE if page mentions any project name from a configured blacklist (passed in opts).
- BOOST confidence if
projects-wikialready has a page with similar title in any domain (jaccard ≥ 0.4 on tokenized title). - Returned shape per candidate:
{ slug, current_path, suggested_domain, confidence, reason }.suggested_domainderived from cwd domain (orcrossif unknown).
Contract:
export interface PromotionCandidate {
slug: string;
current_path: string;
suggested_domain: string;
confidence: 'low' | 'medium' | 'high';
reason: string;
}
export interface PromotionDeps {
projectWikiDir: string; // <cwd>/.wiki
sharedPages: WikiPage[]; // already loaded
cwdDomain: Domain;
blacklist: string[]; // project / domain names
}
export async function findCandidates(deps: PromotionDeps): Promise<PromotionCandidate[]>;
- Step 1: Write failing promotion tests
tests/lib/promotion.test.ts:
import { describe, it, expect, beforeEach } from 'vitest';
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { findCandidates } from '../../src/lib/promotion.js';
import type { WikiPage } from '../../src/lib/wiki-index.js';
let projectWiki: string;
beforeEach(async () => {
const root = await mkdtemp(join(tmpdir(), 'promo-'));
projectWiki = join(root, '.wiki');
await mkdir(join(projectWiki, 'concepts'), { recursive: true });
});
const sharedPages: WikiPage[] = [
{ slug: 'embedded/yarn-on-windows', title: 'Yarn on Windows', domain: 'embedded', tags: [], body: '', raw: '' },
];
describe('findCandidates', () => {
it('includes platform-flavored concept', async () => {
await writeFile(
join(projectWiki, 'concepts', 'windows-yarn-quirk.md'),
'---\ntitle: Windows yarn quirk\n---\n\nWhen yarn on Windows, exec() is required.',
);
const r = await findCandidates({
projectWikiDir: projectWiki,
sharedPages,
cwdDomain: 'node',
blacklist: [],
});
expect(r).toHaveLength(1);
expect(r[0].suggested_domain).toBe('node');
expect(r[0].confidence).toBe('high'); // platform keyword + similar shared title
});
it('excludes when project name in blacklist matched', async () => {
await writeFile(
join(projectWiki, 'concepts', 'snolla-edge-case.md'),
'---\ntitle: Snolla edge case\n---\n\nWhen yarn meets the Snolla pipeline, special handling for npm-mcp.',
);
const r = await findCandidates({
projectWikiDir: projectWiki,
sharedPages,
cwdDomain: 'node',
blacklist: ['snolla', 'npm-mcp'],
});
expect(r).toHaveLength(0);
});
it('returns confidence=medium when keyword match but no similar shared title', async () => {
await writeFile(
join(projectWiki, 'concepts', 'docker-on-linux.md'),
'---\ntitle: Docker on Linux\n---\n\nDocker daemon socket on Linux.',
);
const r = await findCandidates({
projectWikiDir: projectWiki,
sharedPages,
cwdDomain: 'node',
blacklist: [],
});
expect(r).toHaveLength(1);
expect(r[0].confidence).toBe('medium');
});
it('skips concept without platform keyword', async () => {
await writeFile(
join(projectWiki, 'concepts', 'business-rule.md'),
'---\ntitle: Order discount logic\n---\n\nWhen total > 100 apply 5% off.',
);
const r = await findCandidates({
projectWikiDir: projectWiki,
sharedPages,
cwdDomain: 'node',
blacklist: [],
});
expect(r).toHaveLength(0);
});
it('cwdDomain unknown → suggested_domain=cross', async () => {
await writeFile(
join(projectWiki, 'concepts', 'git-trick.md'),
'---\ntitle: Git tip\n---\n\ngit reflog tip.',
);
const r = await findCandidates({
projectWikiDir: projectWiki,
sharedPages,
cwdDomain: 'unknown',
blacklist: [],
});
expect(r[0].suggested_domain).toBe('cross');
});
});
- Step 2: Run, expect FAIL
Run: npm test -- tests/lib/promotion.test.ts
Expected: FAIL.
- Step 3: Implement src/lib/promotion.ts
import { readdir, readFile } from 'node:fs/promises';
import { join } from 'node:path';
import matter from 'gray-matter';
import type { Domain } from './domain-detector.js';
import type { WikiPage } from './wiki-index.js';
export interface PromotionCandidate {
slug: string;
current_path: string;
suggested_domain: string;
confidence: 'low' | 'medium' | 'high';
reason: string;
}
export interface PromotionDeps {
projectWikiDir: string;
sharedPages: WikiPage[];
cwdDomain: Domain;
blacklist: string[];
}
const PLATFORM_KEYWORDS = [
'windows',
'linux',
'macos',
'mac os',
'yarn',
'npm',
'pnpm',
'node',
'mcp',
'git ',
'docker',
'tsx',
'vite',
'webpack',
'eslint',
'tsc',
'powershell',
'bash',
];
function tokenize(s: string): Set<string> {
return new Set(
s
.toLowerCase()
.replace(/[^a-z0-9 ]+/g, ' ')
.split(/\s+/)
.filter((w) => w.length >= 3),
);
}
function jaccard(a: Set<string>, b: Set<string>): number {
if (a.size === 0 && b.size === 0) return 0;
let inter = 0;
for (const x of a) if (b.has(x)) inter += 1;
const union = a.size + b.size - inter;
return union === 0 ? 0 : inter / union;
}
async function listConcepts(dir: string): Promise<string[]> {
try {
const files = await readdir(dir, { withFileTypes: true });
return files.filter((f) => f.isFile() && f.name.endsWith('.md')).map((f) => f.name);
} catch {
return [];
}
}
function suggestDomain(d: Domain): string {
return d === 'unknown' ? 'cross' : d;
}
export async function findCandidates(deps: PromotionDeps): Promise<PromotionCandidate[]> {
const conceptsDir = join(deps.projectWikiDir, 'concepts');
const files = await listConcepts(conceptsDir);
const out: PromotionCandidate[] = [];
for (const f of files) {
const path = join(conceptsDir, f);
const text = await readFile(path, 'utf8');
const { data: fm, content } = matter(text);
const title = typeof fm.title === 'string' ? fm.title : f.replace(/\.md$/, '');
const haystack = `${title}\n${content}`.toLowerCase();
if (deps.blacklist.some((b) => haystack.includes(b.toLowerCase()))) continue;
const matchedKw = PLATFORM_KEYWORDS.find((k) => haystack.includes(k));
if (!matchedKw) continue;
const titleTokens = tokenize(title);
const similar = deps.sharedPages.find((p) => jaccard(titleTokens, tokenize(p.title)) >= 0.4);
const confidence: PromotionCandidate['confidence'] = similar ? 'high' : 'medium';
const reasonParts = [`keyword: ${matchedKw.trim()}`];
if (similar) reasonParts.push(`similar shared page: ${similar.slug}`);
out.push({
slug: f.replace(/\.md$/, ''),
current_path: path,
suggested_domain: suggestDomain(deps.cwdDomain),
confidence,
reason: reasonParts.join('; '),
});
}
return out;
}
- Step 4: Run promotion tests, expect PASS
Run: npm test -- tests/lib/promotion.test.ts
Expected: 5 passed.
- Step 5: Add knowledge.suggest_promote to src/tools/knowledge.ts
Insert this tool after knowledge.get in the returned array of makeKnowledgeTools:
{
name: 'knowledge.suggest_promote',
description:
'Возвращает кандидатов из текущего .wiki/concepts/ на промоушен в projects-wiki. Фильтр по платформенным ключам, исключение по чёрному списку проектов. Решение всегда у пользователя — этот tool не пишет файлы.',
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
async handler() {
const det = await domain();
const shared = await pages();
const candidates = await findCandidates({
projectWikiDir: join(opts.projectCwd, '.wiki'),
sharedPages: shared,
cwdDomain: det,
blacklist: [],
});
return asJson({ candidates });
},
},
Add imports at top of src/tools/knowledge.ts:
import { join } from 'node:path';
import { findCandidates } from '../lib/promotion.js';
- Step 6: Extend tests/tools/knowledge.test.ts with one suggest_promote test
Append to the file:
import { mkdir as _mkdirFs } from 'node:fs/promises';
describe('knowledge.suggest_promote', () => {
it('returns candidates from project .wiki/concepts', async () => {
await _mkdirFs(join(nodeProject, '.wiki', 'concepts'), { recursive: true });
await writeFile(
join(nodeProject, '.wiki', 'concepts', 'docker-quirk.md'),
'---\ntitle: Docker quirk\n---\n\nDocker daemon stuff.',
);
const tools = makeKnowledgeTools({ wikiRoot, projectCwd: nodeProject });
const r = await call(tools, 'knowledge.suggest_promote', {});
const parsed = JSON.parse(r.content[0].text);
expect(parsed.candidates).toHaveLength(1);
expect(parsed.candidates[0].slug).toBe('docker-quirk');
expect(parsed.candidates[0].suggested_domain).toBe('node');
});
});
- Step 7: Run all knowledge tests
Run: npm test -- tests/tools/knowledge.test.ts tests/lib/promotion.test.ts
Expected: all pass.
- Step 8: Commit
git add src/lib/promotion.ts tests/lib/promotion.test.ts src/tools/knowledge.ts tests/tools/knowledge.test.ts
git commit -m "feat(promotion): suggest_promote tool with keyword + similarity heuristic"
Phase 4 — Wiring & smoke
Task 4.1: Build + run all tests
- Step 1: Full typecheck + build
Run: npm run typecheck && npm run build
Expected: zero errors. dist/sync.js and dist/server.js produced.
- Step 2: Full test suite
Run: npm test
Expected: every test from previous tasks PASS, no failures.
- Step 3: Smoke-run sync without auth (expect graceful failure)
Run: node dist/sync.js
Expected: exit code 2 with message auth.toml load failed: ... (auth.toml absent on CI/dev box).
This is the desired graceful behavior; do not commit a fix.
- Step 4: Smoke-run server with stub stdin
Run (Windows PowerShell):
echo '' | node dist/server.js
Expected: process stays alive a moment, exits cleanly when stdin closes. No stack traces.
- Step 5: Commit any incidental fixes (probably none)
If fixes were needed, commit them now. Otherwise skip.
Task 4.2: Update README + record tasks
Files:
-
Modify:
README.md -
Modify:
.tasks/STATUS.md -
Create:
.tasks/bootstrap-implementation.md -
Modify:
.wiki/index.md -
Modify:
.wiki/log.md -
Create:
.wiki/entities/sync-script.md -
Create:
.wiki/entities/mcp-server.md -
Create:
.wiki/entities/domain-detector.md -
Create:
.wiki/packages/modelcontextprotocol-sdk.md -
Create:
.wiki/packages/gray-matter.md -
Create:
.wiki/packages/smol-toml.md -
Create:
.wiki/packages/zod.md -
Step 1: Replace README.md
# projects-meta-mcp
MCP-сервер: агрегирует статусы задач (`.tasks/STATUS.md`) и общие знания (репо `projects-wiki`, локально клонится в `~/projects/.wiki/`) поверх множества проектов и нескольких машин.
- Источник правды: Gitea (`https://git.kzntsv.site`, owner `OpeItcLoc03`).
- Процесс — локальный на каждой машине (offline-критично).
- Данные — локальный кэш + клон `projects-wiki`, синхронизируются скриптом.
## Bootstrap
```bash
git clone https://git.kzntsv.site/OpeItcLoc03/projects-meta-mcp ~/.local/projects-meta-mcp
cd ~/.local/projects-meta-mcp
npm install && npm run build
git clone https://git.kzntsv.site/OpeItcLoc03/projects-wiki ~/projects/.wiki
mkdir -p ~/.config/projects-mcp
cp auth.toml.example ~/.config/projects-mcp/auth.toml
# отредактировать auth.toml — заполнить gitea_token
node dist/sync.js # первый sync
Регистрация в Claude Code
Добавить в ~/.claude.json:
{
"mcpServers": {
"projects-meta": {
"command": "node",
"args": ["C:/Users/<USER>/.local/projects-meta-mcp/dist/server.js"]
}
}
}
Tools
| Tool | Назначение |
|---|---|
tasks.aggregate |
Свод активных задач по всем проектам |
tasks.search |
Поиск по slug/next-action |
tasks.get |
Полный STATUS.md одного проекта |
knowledge.search |
Поиск по shared-вики, фильтр по домену cwd |
knowledge.get |
Полный текст одной страницы |
knowledge.suggest_promote |
Кандидаты на промоушен из .wiki/concepts/ текущего проекта |
meta.status |
Возраст кэша + диагностика |
См. дизайн: docs/superpowers/specs/2026-04-29-projects-meta-mcp-design.md План реализации: docs/superpowers/plans/2026-04-29-bootstrap-implementation.md
- [ ] **Step 2: Add task block to .tasks/STATUS.md**
After the comment block, append:
```markdown
## 🟢 [bootstrap-implementation] — Реализация sync-script + MCP-сервера по спеке
**Status:** done
**Where I stopped:** Все фазы плана выполнены, тесты зелёные, README обновлён.
**Next action:** Юзкейс-проверка на реальной Gitea: положить `auth.toml`, запустить `node dist/sync.js`, дёрнуть MCP из Claude Code.
**Branch:** main
---
- Step 3: Create .tasks/bootstrap-implementation.md
# bootstrap-implementation
## Goal
Реализовать минимальный рабочий `projects-meta-mcp`: sync-script тащит `STATUS.md` всех репо `OpeItcLoc03` через Gitea API в локальный JSON-кэш; MCP-сервер по stdio отдаёт `tasks.*`, `knowledge.*`, `meta.status`. Конец = `npm test` зелёный, `npm run build` успешен, README с bootstrap-инструкцией.
## Key files
- `src/sync.ts` — CLI sync entry
- `src/server.ts` — MCP stdio entry
- `src/lib/config.ts:1` — paths + auth.toml
- `src/lib/gitea.ts:1` — Gitea API wrapper
- `src/lib/sync-runner.ts:1` — orchestrator (concurrency, error-collect)
- `src/lib/status-md.ts:1` — STATUS.md parser
- `src/lib/wiki-index.ts:1` — projects-wiki page loader
- `src/lib/domain-detector.ts:1` — cwd domain heuristic
- `src/lib/promotion.ts:1` — suggest_promote heuristic
- `src/tools/{tasks,knowledge,meta}.ts` — tool factories
## Decisions log
- 2026-04-29: ESM (`"type": "module"`) + NodeNext — ради native fetch и top-level await.
- 2026-04-29: Vitest вместо jest — нативный ESM, быстрее.
- 2026-04-29: `gray-matter` для frontmatter, `smol-toml` для auth.toml, `zod` для tool input validation.
- 2026-04-29: Concurrency limit 10 для Gitea — соответствует спеке.
- 2026-04-29: Stale threshold 24h в `meta.status` — соответствует спеке.
## Open questions
- [ ] Нужно ли в `getRawFile` пытаться `master` если `default_branch` не сработал? (пока: только `default_branch`).
- [ ] Cron / git-hook для запуска sync — вне scope этого таска.
## Completed steps
- [x] Phase 1 — bootstrap (package.json, tsconfig, vitest, auth.toml.example)
- [x] Phase 2 — sync (config, cache, status-md, gitea, sync-runner, sync.ts)
- [x] Phase 3 — server (stdio, tasks tools, domain-detector, knowledge tools, meta, promotion)
- [x] Phase 4 — wiring (README, .tasks, .wiki entities/packages)
## Notes
Pre-merge проверка: положить `~/.config/projects-mcp/auth.toml` с реальным токеном, запустить `node dist/sync.js`, проверить `~/.cache/projects-mcp/tasks.json`. Затем зарегистрировать MCP в `~/.claude.json` и дёрнуть `meta.status` из Claude Code.
- Step 4: Update .wiki/index.md
Replace _Пусто. Появятся при реализации сервера._ and _Пусто. Появятся при npm install зависимостей._ with:
## Сущности
- [sync-script](entities/sync-script.md) — скрипт-синк Gitea API → JSON-кэш.
- [mcp-server](entities/mcp-server.md) — stdio MCP с тулами `tasks.*`, `knowledge.*`, `meta.*`.
- [domain-detector](entities/domain-detector.md) — определение домена cwd по файлам.
## Пакеты
- [@modelcontextprotocol/sdk](packages/modelcontextprotocol-sdk.md) — MCP runtime.
- [gray-matter](packages/gray-matter.md) — frontmatter parser.
- [smol-toml](packages/smol-toml.md) — TOML parser для auth.toml.
- [zod](packages/zod.md) — runtime валидация tool-инпутов.
- Step 5: Create .wiki/entities/sync-script.md
---
title: sync-script
type: entity
tags: [gitea, cache, cli]
sources: []
updated: 2026-04-29
---
# sync-script
Локальный CLI-скрипт. Тащит `STATUS.md` всех репо пользователя через Gitea API, парсит, складывает в `~/.cache/projects-mcp/tasks.json` атомарной записью.
## Файлы
- [src/sync.ts](../../src/sync.ts) — entrypoint, читает `auth.toml`, вызывает runner, пишет кэш и лог.
- [src/lib/sync-runner.ts](../../src/lib/sync-runner.ts) — pure orchestrator: `listUserRepos` → параллельно (concurrency=10) `getRawFile` + `parseStatusMd` → собирает `CacheFile`. Тестируется юнитами через моки.
- [src/lib/gitea.ts](../../src/lib/gitea.ts) — fetch-wrapper, пагинация, 404 → `null`.
- [src/lib/cache.ts](../../src/lib/cache.ts) — атомарная запись через `tasks.json.tmp` + `rename`.
## Контракт
`runSync({ client, parseStatus, now, machine, giteaUrl, user, concurrency? })` → `CacheFile`.
Ошибки сетевых запросов на конкретный репо не валят весь синк — оседают в `errors[]`. 404 на `STATUS.md` = у проекта нет тасок (skip без ошибки).
## Запуск
```bash
node dist/sync.js
Exit codes:
0— sync прошёл (даже с частичными ошибками).2—auth.tomlотсутствует / битый.
- [ ] **Step 6: Create .wiki/entities/mcp-server.md**
```markdown
---
title: mcp-server
type: entity
tags: [mcp, stdio]
sources: []
updated: 2026-04-29
---
# mcp-server
stdio MCP-сервер. Не делает сетевых вызовов в hot path. Читает локальный кэш и клон `projects-wiki`.
## Tools
| Name | Файл |
|------|------|
| `tasks.aggregate`, `tasks.search`, `tasks.get` | [src/tools/tasks.ts](../../src/tools/tasks.ts) |
| `knowledge.search`, `knowledge.get`, `knowledge.suggest_promote` | [src/tools/knowledge.ts](../../src/tools/knowledge.ts) |
| `meta.status` | [src/tools/meta.ts](../../src/tools/meta.ts) |
## CLI args
- `--cwd <path>` — переопределение cwd (для тестов и nonstandard launchers). Иначе `process.cwd()`.
## Запуск
```bash
node dist/server.js
Транспорт — stdio. Регистрируется в ~/.claude.json как mcpServers.projects-meta.
- [ ] **Step 7: Create .wiki/entities/domain-detector.md**
```markdown
---
title: domain-detector
type: entity
tags: [heuristic]
sources: []
updated: 2026-04-29
---
# domain-detector
Определяет домен текущего проекта по файлам в cwd. Используется `knowledge.search` для дефолтного фильтра и `knowledge.suggest_promote` для предлагаемого `domain` в кандидате.
## Алгоритм
1. `package.json` → `node`
2. `platformio.ini` / `*.ino` / `CMakeLists.txt` с `arm-none-eabi`/`STM32`/`ESP_IDF` → `embedded`
3. `next.config.*` / `vite.config.*` / `index.html` (без package.json) → `web`
4. иначе → `unknown`
`package.json` имеет высший приоритет — node-проект со статиком всё равно `node`.
## Файл
- [src/lib/domain-detector.ts](../../src/lib/domain-detector.ts) — pure async function. Тесты — [tests/lib/domain-detector.test.ts](../../tests/lib/domain-detector.test.ts).
- Step 8: Create package pages
.wiki/packages/modelcontextprotocol-sdk.md:
---
title: "@modelcontextprotocol/sdk"
type: package
tags: [mcp, runtime]
updated: 2026-04-29
---
# @modelcontextprotocol/sdk
Официальный TypeScript SDK для MCP. Используется только в [src/server.ts](../../src/server.ts).
- `Server` + `StdioServerTransport` для stdio-режима.
- Хендлеры регистрируются через `setRequestHandler(ListToolsRequestSchema | CallToolRequestSchema, ...)`.
- Для входных схем тулов используется JSON Schema (objects). Runtime-валидация — наша через `zod`.
.wiki/packages/gray-matter.md:
---
title: gray-matter
type: package
tags: [yaml, frontmatter]
updated: 2026-04-29
---
# gray-matter
Парсит YAML frontmatter в markdown. Используется в:
- [src/lib/wiki-index.ts](../../src/lib/wiki-index.ts) — `loadWiki()`
- [src/lib/promotion.ts](../../src/lib/promotion.ts) — `findCandidates()`
API: `matter(text) → { data, content }`.
.wiki/packages/smol-toml.md:
---
title: smol-toml
type: package
tags: [toml]
updated: 2026-04-29
---
# smol-toml
TOML парсер без зависимостей. Используется в [src/lib/config.ts](../../src/lib/config.ts) для `~/.config/projects-mcp/auth.toml`.
API: `parse(text) → object`.
Альтернатива `@iarna/toml` отвергнута — у smol-toml нет depend tree.
.wiki/packages/zod.md:
---
title: zod
type: package
tags: [validation]
updated: 2026-04-29
---
# zod
Runtime-валидация. Используется в:
- [src/lib/config.ts](../../src/lib/config.ts) — схема `auth.toml`
- [src/tools/tasks.ts](../../src/tools/tasks.ts) — input-схемы tasks-тулов
- [src/tools/knowledge.ts](../../src/tools/knowledge.ts) — input-схемы knowledge-тулов
JSON Schema, который видит MCP-клиент, прописан вручную (см. `inputSchema` каждого tool). zod — отдельный санитайзинг внутри handler'а, чтобы поймать малформед-аргументы.
- Step 9: Append to .wiki/log.md
## [2026-04-29] init | Реализация sync-script + MCP-сервера
- Добавлены entity-страницы: sync-script, mcp-server, domain-detector.
- Добавлены package-страницы: @modelcontextprotocol/sdk, gray-matter, smol-toml, zod.
- index.md обновлён: сущности и пакеты больше не пустые.
- README.md теперь содержит bootstrap-инструкцию + tools-таблицу.
- Тесты зелёные, build чистый.
- Step 10: Commit
git add README.md .tasks/STATUS.md .tasks/bootstrap-implementation.md .wiki/index.md .wiki/log.md .wiki/entities .wiki/packages
git commit -m "docs: README bootstrap guide + wiki entity/package pages + task closeout"
Task 4.3: Push to Gitea
- Step 1: Push main
git push origin main
Expected: Multiple commits pushed cleanly. Verify on Gitea UI: git.kzntsv.site/OpeItcLoc03/projects-meta-mcp.
- Step 2: No further commits in this plan.
Verification checklist (post-plan)
After all tasks done:
npm testexits 0, all suites passnpm run buildproducesdist/sync.jsanddist/server.jswithout errorsnpm run typecheckcleannode dist/sync.jswithout auth.toml → exit 2 with helpful messagenode dist/server.jsstarts on stdio without crashinggit log --onelineshows linear history of feat/chore/docs commitsgit push origin mainsucceeded- .tasks/STATUS.md lists task as 🟢 done
- .wiki/index.md lists 3 entities + 4 packages
End state: pre-merge ready. To exercise live: write ~/.config/projects-mcp/auth.toml, run node dist/sync.js, register MCP in Claude Code, call meta.status from a chat session.