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

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

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

3088 lines
94 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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](../specs/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**
```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**
```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**
```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`:
```ts
async function main(): Promise<void> {
console.log('sync: not implemented yet');
}
await main();
```
`src/server.ts`:
```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**
```bash
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**
```toml
# 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**
```bash
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:**
```ts
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`:
```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**
```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**
```bash
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:**
```ts
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`:
```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**
```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**
```bash
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:**
```ts
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`:
```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**
```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**
```bash
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:**
```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>;
// 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`:
```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**
```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**
```bash
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:**
```ts
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`:
```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**
```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**
```bash
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**
```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**
```bash
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**
```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`:
```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`:
```ts
import type { ToolDef } from './types.js';
export function makeTasksTools(_opts: { cacheFile: string }): ToolDef[] {
return [];
}
```
`src/tools/knowledge.ts`:
```ts
import type { ToolDef } from './types.js';
export function makeKnowledgeTools(_opts: {
wikiRoot: string;
projectCwd: string;
}): ToolDef[] {
return [];
}
```
`src/tools/meta.ts`:
```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**
```bash
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 })` → matches `query` (case-insensitive substring) against `slug + 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`:
```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**
```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**
```bash
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:**
```ts
export type Domain = 'node' | 'embedded' | 'web' | 'unknown';
export async function detectDomain(cwd: string): Promise<Domain>;
```
Decision rules (spec §detect domain):
1. `package.json` exists → `node`
2. `platformio.ini` OR any `*.ino` OR `CMakeLists.txt` containing `arm-none-eabi` or `STM32` or `ESP_IDF``embedded`
3. `next.config.*` OR `vite.config.*` OR (`index.html` AND no `package.json`) → `web`
4. else → `unknown`
- [ ] **Step 1: Write failing tests**
`tests/lib/domain-detector.test.ts`:
```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**
```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**
```bash
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:**
```ts
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 }`. Default `limit=10`. Default domain filter = detected from cwd; if cwd domain is `unknown` no filter; if `domain="all"` no filter; otherwise show pages where `domain == filter || domain == "cross"`.
- `knowledge.get({ slug })` → full page contents.
- [ ] **Step 1: Write failing wiki-index tests**
`tests/lib/wiki-index.test.ts`:
```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**
```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**
```bash
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`:
```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**
```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**
```bash
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`:
```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**
```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**
```bash
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-wiki` already 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_domain` derived from cwd domain (or `cross` if unknown).
**Contract:**
```ts
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`:
```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**
```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`:
```ts
{
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`:
```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:
```ts
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**
```bash
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):
```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**
```markdown
# 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`:
```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/specs/2026-04-29-projects-meta-mcp-design.md)
План реализации: [docs/superpowers/plans/2026-04-29-bootstrap-implementation.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**
```markdown
# 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:
```markdown
## Сущности
- [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**
```markdown
---
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`:
```markdown
---
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`:
```markdown
---
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`:
```markdown
---
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`:
```markdown
---
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**
```markdown
## [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**
```bash
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**
```bash
git push origin main
```
Expected: Multiple commits pushed cleanly. Verify on Gitea UI: [git.kzntsv.site/OpeItcLoc03/projects-meta-mcp](https://git.kzntsv.site/OpeItcLoc03/projects-meta-mcp).
- [ ] **Step 2: No further commits in this plan.**
---
## Verification checklist (post-plan)
After all tasks done:
- [ ] `npm test` exits 0, all suites pass
- [ ] `npm run build` produces `dist/sync.js` and `dist/server.js` without errors
- [ ] `npm run typecheck` clean
- [ ] `node dist/sync.js` without auth.toml → exit 2 with helpful message
- [ ] `node dist/server.js` starts on stdio without crashing
- [ ] `git log --oneline` shows linear history of feat/chore/docs commits
- [ ] `git push origin main` succeeded
- [ ] [.tasks/STATUS.md](../../.tasks/STATUS.md) lists task as 🟢 done
- [ ] [.wiki/index.md](../../.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.