# 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 { console.log('sync: not implemented yet'); } await main(); ``` `src/server.ts`: ```ts async function main(): Promise { 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; // /tasks.json syncLog: string; // /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; // 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 { 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; export async function readCache(file: string): Promise; // 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 { 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 { 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 `## [] — `. 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 = { '🔴': '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; getRawFile(user: string, repo: string, path: string, branch: string): Promise; // 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) { 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)['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; getRawFile( user: string, repo: string, path: string, branch: string, ): Promise; } 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 { const res = await fetchImpl(url, { headers }); return res; } return { async listUserRepos(user: string): Promise { 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; ``` - [ ] **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): 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(items: T[], n: number, fn: (x: T) => Promise): Promise { const out = new Array(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 { 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(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 { 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 ` 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 { 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; required?: string[]; additionalProperties?: boolean; }; handler: (args: Record) => Promise; } ``` - [ ] **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, name: string, args: Record) { 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; ``` 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 { 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'), ''); 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'), ''); 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 { try { await stat(p); return true; } catch { return false; } } async function listFiles(dir: string): Promise { 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 { 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; ``` 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 { 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 { 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; 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, name: string, args: Record) { 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 { if (cached) return cached; cached = await loadWiki(opts.wikiRoot); return cached; } let detectedDomain: Domain | null = null; async function domain(): Promise { 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; // /.wiki sharedPages: WikiPage[]; // already loaded cwdDomain: Domain; blacklist: string[]; // project / domain names } export async function findCandidates(deps: PromotionDeps): Promise; ``` - [ ] **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 { return new Set( s .toLowerCase() .replace(/[^a-z0-9 ]+/g, ' ') .split(/\s+/) .filter((w) => w.length >= 3), ); } function jaccard(a: Set, b: Set): 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 { 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 { 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//.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 ` — переопределение 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.