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

94 KiB
Raw Permalink Blame History

projects-meta-mcp Bootstrap Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Реализовать sync-script (Gitea API → JSON-кэш) и stdio MCP-сервер с тулами tasks.*, knowledge.*, meta.status — согласно спеке 2026-04-29-projects-meta-mcp-design.md. Конечное состояние: после npm run build и node dist/sync.js локальный ~/.cache/projects-mcp/tasks.json содержит реальные STATUS.md всех проектов owner'a OpeItcLoc03, node dist/server.js стартует stdio MCP и отвечает на все определённые tools.

Architecture: Один TS-пакет, два бинарника: dist/sync.js (CLI, делает сетевые вызовы) и dist/server.js (MCP stdio, читает только локальные файлы). Общая логика (парсеры, конфиг, кэш) — в src/lib/. ESM ("type": "module"), TypeScript NodeNext, native fetch.

Tech Stack: Node 22, TypeScript 5, @modelcontextprotocol/sdk, gray-matter (frontmatter), smol-toml (auth.toml), zod (схемы tool-инпутов), vitest (тесты).


File structure

projects-meta-mcp/
├── package.json
├── tsconfig.json
├── vitest.config.ts
├── auth.toml.example
├── .gitignore                       # дополнить
├── src/
│   ├── sync.ts                      # CLI entry: npm run sync
│   ├── server.ts                    # MCP entry: npm run start
│   ├── lib/
│   │   ├── config.ts                # paths + auth.toml loader
│   │   ├── cache.ts                 # atomic JSON write/read
│   │   ├── gitea.ts                 # fetch wrapper for Gitea API
│   │   ├── status-md.ts             # parse STATUS.md → ParsedStatus
│   │   ├── domain-detector.ts       # detect cwd domain
│   │   ├── frontmatter.ts           # parse wiki page frontmatter
│   │   └── promotion.ts             # candidate heuristic
│   └── tools/
│       ├── tasks.ts                 # tasks.aggregate/search/get handlers
│       ├── knowledge.ts             # knowledge.search/get/suggest_promote
│       └── meta.ts                  # meta.status
└── tests/
    └── (mirrors src/lib/ and src/tools/)

File size discipline: lib/ files ≤ 150 lines each. Если файл вырастает — split.

Commits: один TDD-цикл (test → impl → green → commit) = один коммит. Сообщения в Conventional Commits, scope = file/module.


Phase 1 — Bootstrap

Task 1.1: Initialize npm package + TypeScript

Files:

  • Create: package.json

  • Create: tsconfig.json

  • Create: vitest.config.ts

  • Create: src/sync.ts (skeleton)

  • Create: src/server.ts (skeleton)

  • Step 1: Create package.json

{
  "name": "projects-meta-mcp",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "bin": {
    "projects-meta-mcp": "dist/server.js",
    "projects-meta-sync": "dist/sync.js"
  },
  "scripts": {
    "build": "tsc",
    "start": "node dist/server.js",
    "sync": "node dist/sync.js",
    "test": "vitest run",
    "test:watch": "vitest",
    "typecheck": "tsc --noEmit"
  },
  "dependencies": {
    "@modelcontextprotocol/sdk": "^1.0.0",
    "gray-matter": "^4.0.3",
    "smol-toml": "^1.3.0",
    "zod": "^3.23.0"
  },
  "devDependencies": {
    "@types/node": "^22.0.0",
    "typescript": "^5.6.0",
    "vitest": "^2.1.0"
  },
  "engines": {
    "node": ">=22"
  }
}
  • Step 2: Create tsconfig.json
{
  "compilerOptions": {
    "target": "ES2023",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "lib": ["ES2023"],
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "declaration": false,
    "sourceMap": true,
    "resolveJsonModule": true,
    "verbatimModuleSyntax": true
  },
  "include": ["src/**/*"],
  "exclude": ["dist", "node_modules", "tests"]
}
  • Step 3: Create vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    include: ['tests/**/*.test.ts'],
    environment: 'node',
  },
});
  • Step 4: Create src/sync.ts and src/server.ts skeletons

src/sync.ts:

async function main(): Promise<void> {
  console.log('sync: not implemented yet');
}

await main();

src/server.ts:

async function main(): Promise<void> {
  console.error('server: not implemented yet');
}

await main();
  • Step 5: Run npm install

Run: npm install Expected: node_modules/ populated, package-lock.json created, no errors.

  • Step 6: Verify build works

Run: npm run build && node dist/sync.js && node dist/server.js Expected: skeleton outputs without crash. dist/ contains .js files.

  • Step 7: Add dist/ and node_modules to .gitignore (already there)

Verify .gitignore already contains node_modules/ and dist/. If not, add.

  • Step 8: Commit
git add package.json package-lock.json tsconfig.json vitest.config.ts src/sync.ts src/server.ts
git commit -m "chore: scaffold TS package with sync + server entries"

Task 1.2: Auth example + cache dir convention

Files:

  • Create: auth.toml.example

  • Step 1: Create auth.toml.example

# projects-meta-mcp auth config
# Copy to ~/.config/projects-mcp/auth.toml and fill gitea_token.

gitea_url   = "https://git.kzntsv.site"
gitea_user  = "OpeItcLoc03"
gitea_token = "REPLACE_WITH_PERSONAL_ACCESS_TOKEN"  # scope: read:repository
  • Step 2: Commit
git add auth.toml.example
git commit -m "chore: add auth.toml.example"

Phase 2 — sync-script

Task 2.1: Config loader

Files:

  • Create: src/lib/config.ts
  • Create: tests/lib/config.test.ts

Contract:

export interface AuthConfig {
  giteaUrl: string;       // no trailing slash
  giteaUser: string;
  giteaToken: string;
}

export interface Paths {
  cacheDir: string;       // ~/.cache/projects-mcp
  cacheFile: string;      // <cacheDir>/tasks.json
  syncLog: string;        // <cacheDir>/sync.log
  authFile: string;       // ~/.config/projects-mcp/auth.toml
  sharedWikiClone: string; // ~/projects/.wiki
}

export function getPaths(home: string): Paths;
export function loadAuth(authFile: string): Promise<AuthConfig>; // throws on missing/malformed
  • Step 1: Write failing tests

tests/lib/config.test.ts:

import { describe, it, expect } from 'vitest';
import { mkdtemp, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { getPaths, loadAuth } from '../../src/lib/config.js';

describe('getPaths', () => {
  it('builds canonical paths from $HOME', () => {
    const p = getPaths('/home/u');
    expect(p.cacheDir).toBe('/home/u/.cache/projects-mcp');
    expect(p.cacheFile).toBe('/home/u/.cache/projects-mcp/tasks.json');
    expect(p.authFile).toBe('/home/u/.config/projects-mcp/auth.toml');
    expect(p.sharedWikiClone).toBe('/home/u/projects/.wiki');
  });
});

describe('loadAuth', () => {
  it('parses well-formed auth.toml', async () => {
    const dir = await mkdtemp(join(tmpdir(), 'auth-'));
    const file = join(dir, 'auth.toml');
    await writeFile(
      file,
      [
        'gitea_url   = "https://git.kzntsv.site"',
        'gitea_user  = "OpeItcLoc03"',
        'gitea_token = "abc123"',
      ].join('\n'),
    );
    const cfg = await loadAuth(file);
    expect(cfg.giteaUrl).toBe('https://git.kzntsv.site');
    expect(cfg.giteaUser).toBe('OpeItcLoc03');
    expect(cfg.giteaToken).toBe('abc123');
  });

  it('strips trailing slash from gitea_url', async () => {
    const dir = await mkdtemp(join(tmpdir(), 'auth-'));
    const file = join(dir, 'auth.toml');
    await writeFile(
      file,
      'gitea_url = "https://git.kzntsv.site/"\ngitea_user = "x"\ngitea_token = "y"\n',
    );
    const cfg = await loadAuth(file);
    expect(cfg.giteaUrl).toBe('https://git.kzntsv.site');
  });

  it('throws on missing field', async () => {
    const dir = await mkdtemp(join(tmpdir(), 'auth-'));
    const file = join(dir, 'auth.toml');
    await writeFile(file, 'gitea_url = "x"\ngitea_user = "y"\n');
    await expect(loadAuth(file)).rejects.toThrow(/gitea_token/);
  });
});
  • Step 2: Run, expect FAIL

Run: npm test -- tests/lib/config.test.ts Expected: FAIL — no src/lib/config.ts.

  • Step 3: Implement src/lib/config.ts
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { parse as parseToml } from 'smol-toml';
import { z } from 'zod';

export interface AuthConfig {
  giteaUrl: string;
  giteaUser: string;
  giteaToken: string;
}

export interface Paths {
  cacheDir: string;
  cacheFile: string;
  syncLog: string;
  authFile: string;
  sharedWikiClone: string;
}

export function getPaths(home: string): Paths {
  const cacheDir = join(home, '.cache', 'projects-mcp');
  return {
    cacheDir,
    cacheFile: join(cacheDir, 'tasks.json'),
    syncLog: join(cacheDir, 'sync.log'),
    authFile: join(home, '.config', 'projects-mcp', 'auth.toml'),
    sharedWikiClone: join(home, 'projects', '.wiki'),
  };
}

const AuthSchema = z.object({
  gitea_url: z.string().min(1),
  gitea_user: z.string().min(1),
  gitea_token: z.string().min(1),
});

export async function loadAuth(authFile: string): Promise<AuthConfig> {
  const raw = await readFile(authFile, 'utf8');
  const parsed = AuthSchema.parse(parseToml(raw));
  return {
    giteaUrl: parsed.gitea_url.replace(/\/+$/, ''),
    giteaUser: parsed.gitea_user,
    giteaToken: parsed.gitea_token,
  };
}
  • Step 4: Run, expect PASS

Run: npm test -- tests/lib/config.test.ts Expected: 3 passed.

  • Step 5: Commit
git add src/lib/config.ts tests/lib/config.test.ts
git commit -m "feat(config): add path resolver + auth.toml loader"

Task 2.2: Atomic cache writer

Files:

  • Create: src/lib/cache.ts
  • Create: tests/lib/cache.test.ts

Contract:

export interface ProjectStatus {
  name: string;
  default_branch: string;
  fetched_at: string;       // ISO
  active_tasks: ActiveTask[];
  all_tasks_count: number;
  raw: string;              // full STATUS.md
}

export interface ActiveTask {
  slug: string;
  status: 'active' | 'paused' | 'blocked' | 'ready' | 'done';
  next: string | null;
}

export interface SyncError {
  project: string;
  reason: string;           // free-form
}

export interface CacheFile {
  synced_at: string;
  synced_from: string;
  machine: string;
  projects: ProjectStatus[];
  errors: SyncError[];
}

export async function writeCache(file: string, data: CacheFile): Promise<void>;
export async function readCache(file: string): Promise<CacheFile | null>; // null when missing
  • Step 1: Write failing tests

tests/lib/cache.test.ts:

import { describe, it, expect } from 'vitest';
import { mkdtemp, readFile, stat } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { writeCache, readCache, type CacheFile } from '../../src/lib/cache.js';

const sample: CacheFile = {
  synced_at: '2026-04-29T12:00:00.000Z',
  synced_from: 'https://git.kzntsv.site',
  machine: 'test',
  projects: [],
  errors: [],
};

describe('cache', () => {
  it('writes JSON atomically and reads it back', async () => {
    const dir = await mkdtemp(join(tmpdir(), 'cache-'));
    const file = join(dir, 'tasks.json');
    await writeCache(file, sample);
    const back = await readCache(file);
    expect(back).toEqual(sample);
  });

  it('does not leave .tmp file on success', async () => {
    const dir = await mkdtemp(join(tmpdir(), 'cache-'));
    const file = join(dir, 'tasks.json');
    await writeCache(file, sample);
    await expect(stat(file + '.tmp')).rejects.toThrow();
  });

  it('returns null when cache file missing', async () => {
    const dir = await mkdtemp(join(tmpdir(), 'cache-'));
    const back = await readCache(join(dir, 'missing.json'));
    expect(back).toBeNull();
  });

  it('creates parent directory if missing', async () => {
    const dir = await mkdtemp(join(tmpdir(), 'cache-'));
    const file = join(dir, 'nested', 'deep', 'tasks.json');
    await writeCache(file, sample);
    const txt = await readFile(file, 'utf8');
    expect(JSON.parse(txt)).toEqual(sample);
  });
});
  • Step 2: Run, expect FAIL

Run: npm test -- tests/lib/cache.test.ts Expected: FAIL — no module.

  • Step 3: Implement src/lib/cache.ts
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import { dirname } from 'node:path';

export interface ActiveTask {
  slug: string;
  status: 'active' | 'paused' | 'blocked' | 'ready' | 'done';
  next: string | null;
}

export interface ProjectStatus {
  name: string;
  default_branch: string;
  fetched_at: string;
  active_tasks: ActiveTask[];
  all_tasks_count: number;
  raw: string;
}

export interface SyncError {
  project: string;
  reason: string;
}

export interface CacheFile {
  synced_at: string;
  synced_from: string;
  machine: string;
  projects: ProjectStatus[];
  errors: SyncError[];
}

export async function writeCache(file: string, data: CacheFile): Promise<void> {
  await mkdir(dirname(file), { recursive: true });
  const tmp = `${file}.tmp`;
  await writeFile(tmp, JSON.stringify(data, null, 2), 'utf8');
  await rename(tmp, file);
}

export async function readCache(file: string): Promise<CacheFile | null> {
  try {
    const txt = await readFile(file, 'utf8');
    return JSON.parse(txt) as CacheFile;
  } catch (err: unknown) {
    if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null;
    throw err;
  }
}
  • Step 4: Run, expect PASS

Run: npm test -- tests/lib/cache.test.ts Expected: 4 passed.

  • Step 5: Commit
git add src/lib/cache.ts tests/lib/cache.test.ts
git commit -m "feat(cache): atomic tasks.json read/write"

Task 2.3: STATUS.md parser

Files:

  • Create: src/lib/status-md.ts
  • Create: tests/lib/status-md.test.ts

Contract:

export interface ParsedTask {
  slug: string;
  status: 'active' | 'paused' | 'blocked' | 'ready' | 'done';
  description: string;
  next: string | null;
}

export interface ParsedStatus {
  updated: string | null;
  tasks: ParsedTask[];
}

export function parseStatusMd(text: string): ParsedStatus;

Format reference: Each task starts with ## <emoji> [<slug>] — <description>. Body has bold-prefixed fields. Emoji map: 🔴=active, 🟡=paused, =ready, 🟢=done, 🔵=blocked.

  • Step 1: Write failing tests

tests/lib/status-md.test.ts:

import { describe, it, expect } from 'vitest';
import { parseStatusMd } from '../../src/lib/status-md.js';

const sample = `# Task Board
_Updated: 2026-04-29_

## 🔴 [auth-rewrite] — Rebuild OAuth pipeline
**Status:** active
**Where I stopped:** Mid-refactor of token validator
**Next action:** Add unit tests for refresh flow
**Branch:** feat/auth-rewrite

---

## 🟡 [docs-sweep] — Refresh README
**Status:** paused
**Where I stopped:** Drafted intro, blocked on screenshots
**Next action:** Capture login screencast
**Branch:** docs/readme

---

## 🔵 [vendor-bug] — Upstream pagination broken
**Status:** blocked
**Blocker:** Waiting on vendor patch v2.4.1
**Next action:** Re-run integration test once patched
**Branch:** fix/vendor-bug

---
`;

describe('parseStatusMd', () => {
  it('extracts updated timestamp', () => {
    const r = parseStatusMd(sample);
    expect(r.updated).toBe('2026-04-29');
  });

  it('parses three tasks with correct statuses', () => {
    const r = parseStatusMd(sample);
    expect(r.tasks.map((t) => t.slug)).toEqual([
      'auth-rewrite',
      'docs-sweep',
      'vendor-bug',
    ]);
    expect(r.tasks.map((t) => t.status)).toEqual(['active', 'paused', 'blocked']);
  });

  it('captures description and next action', () => {
    const r = parseStatusMd(sample);
    expect(r.tasks[0].description).toBe('Rebuild OAuth pipeline');
    expect(r.tasks[0].next).toBe('Add unit tests for refresh flow');
  });

  it('returns empty tasks when board empty', () => {
    const r = parseStatusMd('# Task Board\n_Updated: 2026-04-29_\n');
    expect(r.tasks).toEqual([]);
    expect(r.updated).toBe('2026-04-29');
  });

  it('returns null updated when missing', () => {
    expect(parseStatusMd('# Task Board\n').updated).toBeNull();
  });

  it('treats unknown emoji as ready (best effort)', () => {
    const txt = '## ⚫ [weird-task] — desc\n**Next action:** something\n';
    const r = parseStatusMd(txt);
    expect(r.tasks[0].status).toBe('ready');
  });
});
  • Step 2: Run, expect FAIL

Run: npm test -- tests/lib/status-md.test.ts Expected: FAIL — module missing.

  • Step 3: Implement src/lib/status-md.ts
export type TaskStatus = 'active' | 'paused' | 'blocked' | 'ready' | 'done';

export interface ParsedTask {
  slug: string;
  status: TaskStatus;
  description: string;
  next: string | null;
}

export interface ParsedStatus {
  updated: string | null;
  tasks: ParsedTask[];
}

const EMOJI_TO_STATUS: Record<string, TaskStatus> = {
  '🔴': 'active',
  '🟡': 'paused',
  '⚪': 'ready',
  '🟢': 'done',
  '🔵': 'blocked',
};

const TASK_HEADER = /^##\s+(\S+)\s+\[([^\]]+)\]\s+—\s+(.+)$/u;
const NEXT_FIELD = /^\*\*Next action:\*\*\s*(.+)$/i;
const UPDATED_FIELD = /^_Updated:\s*([0-9-]+)_/i;

export function parseStatusMd(text: string): ParsedStatus {
  const lines = text.split(/\r?\n/);
  let updated: string | null = null;
  const tasks: ParsedTask[] = [];
  let current: ParsedTask | null = null;

  for (const line of lines) {
    if (!updated) {
      const m = line.match(UPDATED_FIELD);
      if (m) updated = m[1];
    }

    const head = line.match(TASK_HEADER);
    if (head) {
      if (current) tasks.push(current);
      const [, emoji, slug, description] = head;
      current = {
        slug,
        status: EMOJI_TO_STATUS[emoji] ?? 'ready',
        description: description.trim(),
        next: null,
      };
      continue;
    }

    if (current) {
      const m = line.match(NEXT_FIELD);
      if (m) current.next = m[1].trim();
    }
  }

  if (current) tasks.push(current);
  return { updated, tasks };
}
  • Step 4: Run, expect PASS

Run: npm test -- tests/lib/status-md.test.ts Expected: 6 passed.

  • Step 5: Commit
git add src/lib/status-md.ts tests/lib/status-md.test.ts
git commit -m "feat(status-md): parser for task board format"

Task 2.4: Gitea API client

Files:

  • Create: src/lib/gitea.ts
  • Create: tests/lib/gitea.test.ts

Contract:

export interface GiteaRepo {
  name: string;
  default_branch: string;
}

export interface GiteaClient {
  listUserRepos(user: string): Promise<GiteaRepo[]>;
  getRawFile(user: string, repo: string, path: string, branch: string): Promise<string | null>;
  // returns null on 404; throws on network/5xx/auth errors
}

export function makeGiteaClient(opts: {
  baseUrl: string;
  token: string;
  fetchImpl?: typeof fetch;
}): GiteaClient;
  • Step 1: Write failing tests

tests/lib/gitea.test.ts:

import { describe, it, expect } from 'vitest';
import { makeGiteaClient } from '../../src/lib/gitea.js';

function mockFetch(handler: (url: string, init?: RequestInit) => Response | Promise<Response>) {
  return async (input: string | URL | Request, init?: RequestInit) => {
    const url = typeof input === 'string' ? input : input.toString();
    return handler(url, init);
  };
}

describe('GiteaClient', () => {
  it('lists user repos with token in header', async () => {
    let receivedAuth = '';
    const fetchImpl = mockFetch((url, init) => {
      receivedAuth = (init?.headers as Record<string, string>)['Authorization'];
      expect(url).toBe('https://g/api/v1/users/u/repos?limit=50');
      return new Response(
        JSON.stringify([
          { name: 'r1', default_branch: 'main' },
          { name: 'r2', default_branch: 'master' },
        ]),
        { status: 200, headers: { 'content-type': 'application/json' } },
      );
    });
    const c = makeGiteaClient({ baseUrl: 'https://g', token: 't', fetchImpl: fetchImpl as never });
    const repos = await c.listUserRepos('u');
    expect(repos).toEqual([
      { name: 'r1', default_branch: 'main' },
      { name: 'r2', default_branch: 'master' },
    ]);
    expect(receivedAuth).toBe('token t');
  });

  it('paginates listUserRepos', async () => {
    const pages = [
      [{ name: 'r1', default_branch: 'main' }, { name: 'r2', default_branch: 'main' }],
      [{ name: 'r3', default_branch: 'main' }],
      [],
    ];
    let i = 0;
    const fetchImpl = mockFetch(() => {
      const body = JSON.stringify(pages[i++]);
      return new Response(body, { status: 200, headers: { 'content-type': 'application/json' } });
    });
    const c = makeGiteaClient({ baseUrl: 'https://g', token: 't', fetchImpl: fetchImpl as never });
    const repos = await c.listUserRepos('u');
    expect(repos.map((r) => r.name)).toEqual(['r1', 'r2', 'r3']);
  });

  it('returns null on 404 raw file', async () => {
    const fetchImpl = mockFetch(() => new Response('Not Found', { status: 404 }));
    const c = makeGiteaClient({ baseUrl: 'https://g', token: 't', fetchImpl: fetchImpl as never });
    const r = await c.getRawFile('u', 'repo', '.tasks/STATUS.md', 'main');
    expect(r).toBeNull();
  });

  it('returns body on 200 raw file', async () => {
    const fetchImpl = mockFetch((url) => {
      expect(url).toBe('https://g/api/v1/repos/u/repo/raw/.tasks/STATUS.md?ref=main');
      return new Response('# Task Board\n', { status: 200 });
    });
    const c = makeGiteaClient({ baseUrl: 'https://g', token: 't', fetchImpl: fetchImpl as never });
    const r = await c.getRawFile('u', 'repo', '.tasks/STATUS.md', 'main');
    expect(r).toBe('# Task Board\n');
  });

  it('throws on 500', async () => {
    const fetchImpl = mockFetch(() => new Response('boom', { status: 500 }));
    const c = makeGiteaClient({ baseUrl: 'https://g', token: 't', fetchImpl: fetchImpl as never });
    await expect(c.getRawFile('u', 'r', 'p', 'main')).rejects.toThrow(/500/);
  });

  it('throws on 401', async () => {
    const fetchImpl = mockFetch(() => new Response('nope', { status: 401 }));
    const c = makeGiteaClient({ baseUrl: 'https://g', token: 't', fetchImpl: fetchImpl as never });
    await expect(c.listUserRepos('u')).rejects.toThrow(/401/);
  });
});
  • Step 2: Run, expect FAIL

Run: npm test -- tests/lib/gitea.test.ts Expected: FAIL — module missing.

  • Step 3: Implement src/lib/gitea.ts
export interface GiteaRepo {
  name: string;
  default_branch: string;
}

export interface GiteaClient {
  listUserRepos(user: string): Promise<GiteaRepo[]>;
  getRawFile(
    user: string,
    repo: string,
    path: string,
    branch: string,
  ): Promise<string | null>;
}

interface Options {
  baseUrl: string;
  token: string;
  fetchImpl?: typeof fetch;
}

const PAGE_SIZE = 50;

export function makeGiteaClient(opts: Options): GiteaClient {
  const fetchImpl = opts.fetchImpl ?? fetch;
  const baseUrl = opts.baseUrl.replace(/\/+$/, '');
  const headers = { Authorization: `token ${opts.token}` };

  async function call(url: string): Promise<Response> {
    const res = await fetchImpl(url, { headers });
    return res;
  }

  return {
    async listUserRepos(user: string): Promise<GiteaRepo[]> {
      const repos: GiteaRepo[] = [];
      let page = 1;
      while (true) {
        const url =
          `${baseUrl}/api/v1/users/${encodeURIComponent(user)}/repos?limit=${PAGE_SIZE}` +
          (page > 1 ? `&page=${page}` : '');
        const res = await call(url);
        if (!res.ok) {
          throw new Error(`Gitea listUserRepos ${res.status}: ${await res.text()}`);
        }
        const batch = (await res.json()) as Array<{ name: string; default_branch: string }>;
        if (batch.length === 0) break;
        for (const r of batch) repos.push({ name: r.name, default_branch: r.default_branch });
        if (batch.length < PAGE_SIZE) break;
        page += 1;
      }
      return repos;
    },

    async getRawFile(user, repo, path, branch) {
      const url = `${baseUrl}/api/v1/repos/${encodeURIComponent(user)}/${encodeURIComponent(
        repo,
      )}/raw/${path}?ref=${encodeURIComponent(branch)}`;
      const res = await call(url);
      if (res.status === 404) return null;
      if (!res.ok) {
        throw new Error(`Gitea getRawFile ${res.status}: ${await res.text()}`);
      }
      return await res.text();
    },
  };
}
  • Step 4: Run, expect PASS

Run: npm test -- tests/lib/gitea.test.ts Expected: 6 passed.

  • Step 5: Commit
git add src/lib/gitea.ts tests/lib/gitea.test.ts
git commit -m "feat(gitea): list-repos + raw-file client with pagination"

Task 2.5: Sync orchestrator

Files:

  • Create: src/lib/sync-runner.ts
  • Create: tests/lib/sync-runner.test.ts

Contract:

export interface SyncDeps {
  client: GiteaClient;
  parseStatus: (raw: string) => ParsedStatus;
  now: () => Date;
  machine: string;
  giteaUrl: string;
  user: string;
  concurrency?: number; // default 10
}

export async function runSync(deps: SyncDeps): Promise<CacheFile>;
  • Step 1: Write failing tests

tests/lib/sync-runner.test.ts:

import { describe, it, expect } from 'vitest';
import { runSync } from '../../src/lib/sync-runner.js';
import { parseStatusMd } from '../../src/lib/status-md.js';
import type { GiteaClient } from '../../src/lib/gitea.js';

function fakeClient(repos: Array<{ name: string; default_branch: string }>, files: Record<string, string | null | Error>): GiteaClient {
  return {
    async listUserRepos() {
      return repos;
    },
    async getRawFile(_u, repo) {
      const v = files[repo];
      if (v instanceof Error) throw v;
      return v ?? null;
    },
  };
}

describe('runSync', () => {
  const now = () => new Date('2026-04-29T12:00:00Z');
  const sample = `# Task Board\n_Updated: 2026-04-29_\n\n## 🔴 [t1] — first\n**Next action:** do x\n`;

  it('aggregates STATUS.md across repos', async () => {
    const cache = await runSync({
      client: fakeClient(
        [{ name: 'a', default_branch: 'main' }, { name: 'b', default_branch: 'main' }],
        { a: sample, b: sample },
      ),
      parseStatus: parseStatusMd,
      now,
      machine: 'm',
      giteaUrl: 'https://g',
      user: 'u',
    });
    expect(cache.projects).toHaveLength(2);
    expect(cache.errors).toEqual([]);
    expect(cache.projects[0].active_tasks[0].slug).toBe('t1');
  });

  it('skips 404 (no STATUS.md) without error', async () => {
    const cache = await runSync({
      client: fakeClient(
        [{ name: 'a', default_branch: 'main' }, { name: 'no-tasks', default_branch: 'main' }],
        { a: sample, 'no-tasks': null },
      ),
      parseStatus: parseStatusMd,
      now,
      machine: 'm',
      giteaUrl: 'https://g',
      user: 'u',
    });
    expect(cache.projects.map((p) => p.name)).toEqual(['a']);
    expect(cache.errors).toEqual([]);
  });

  it('records error and continues on network failure', async () => {
    const cache = await runSync({
      client: fakeClient(
        [{ name: 'a', default_branch: 'main' }, { name: 'broken', default_branch: 'main' }],
        { a: sample, broken: new Error('network down') },
      ),
      parseStatus: parseStatusMd,
      now,
      machine: 'm',
      giteaUrl: 'https://g',
      user: 'u',
    });
    expect(cache.projects.map((p) => p.name)).toEqual(['a']);
    expect(cache.errors).toEqual([{ project: 'broken', reason: 'network down' }]);
  });

  it('sets synced_at, machine, synced_from', async () => {
    const cache = await runSync({
      client: fakeClient([], {}),
      parseStatus: parseStatusMd,
      now,
      machine: 'host-x',
      giteaUrl: 'https://g',
      user: 'u',
    });
    expect(cache.synced_at).toBe('2026-04-29T12:00:00.000Z');
    expect(cache.machine).toBe('host-x');
    expect(cache.synced_from).toBe('https://g');
  });
});
  • Step 2: Run, expect FAIL

Run: npm test -- tests/lib/sync-runner.test.ts Expected: FAIL.

  • Step 3: Implement src/lib/sync-runner.ts
import type { CacheFile, ProjectStatus, ActiveTask, SyncError } from './cache.js';
import type { GiteaClient } from './gitea.js';
import type { ParsedStatus, TaskStatus } from './status-md.js';

export interface SyncDeps {
  client: GiteaClient;
  parseStatus: (raw: string) => ParsedStatus;
  now: () => Date;
  machine: string;
  giteaUrl: string;
  user: string;
  concurrency?: number;
}

const ACTIVE_STATES: TaskStatus[] = ['active', 'paused', 'blocked'];

async function pool<T, R>(items: T[], n: number, fn: (x: T) => Promise<R>): Promise<R[]> {
  const out = new Array<R>(items.length);
  let cursor = 0;
  async function worker() {
    while (true) {
      const i = cursor++;
      if (i >= items.length) return;
      out[i] = await fn(items[i]);
    }
  }
  await Promise.all(Array.from({ length: Math.min(n, items.length) }, worker));
  return out;
}

export async function runSync(deps: SyncDeps): Promise<CacheFile> {
  const concurrency = deps.concurrency ?? 10;
  const repos = await deps.client.listUserRepos(deps.user);
  const projects: ProjectStatus[] = [];
  const errors: SyncError[] = [];

  type Outcome =
    | { kind: 'project'; data: ProjectStatus }
    | { kind: 'skip' }
    | { kind: 'error'; data: SyncError };

  const results = await pool<typeof repos[number], Outcome>(repos, concurrency, async (repo) => {
    try {
      const raw = await deps.client.getRawFile(deps.user, repo.name, '.tasks/STATUS.md', repo.default_branch);
      if (raw === null) return { kind: 'skip' as const };
      const parsed = deps.parseStatus(raw);
      const active: ActiveTask[] = parsed.tasks
        .filter((t) => ACTIVE_STATES.includes(t.status))
        .map((t) => ({ slug: t.slug, status: t.status, next: t.next }));
      return {
        kind: 'project' as const,
        data: {
          name: repo.name,
          default_branch: repo.default_branch,
          fetched_at: deps.now().toISOString(),
          active_tasks: active,
          all_tasks_count: parsed.tasks.length,
          raw,
        },
      };
    } catch (err) {
      const reason = err instanceof Error ? err.message : String(err);
      return { kind: 'error' as const, data: { project: repo.name, reason } };
    }
  });

  for (const r of results) {
    if (r.kind === 'project') projects.push(r.data);
    else if (r.kind === 'error') errors.push(r.data);
  }

  return {
    synced_at: deps.now().toISOString(),
    synced_from: deps.giteaUrl,
    machine: deps.machine,
    projects,
    errors,
  };
}
  • Step 4: Run, expect PASS

Run: npm test -- tests/lib/sync-runner.test.ts Expected: 4 passed.

  • Step 5: Commit
git add src/lib/sync-runner.ts tests/lib/sync-runner.test.ts
git commit -m "feat(sync): orchestrator with concurrency, 404-skip, error-collect"

Task 2.6: Sync CLI entry

Files:

  • Modify: src/sync.ts (replace skeleton)

  • Step 1: Implement src/sync.ts

import { homedir, hostname } from 'node:os';
import { appendFile, mkdir } from 'node:fs/promises';
import { dirname } from 'node:path';
import { writeCache } from './lib/cache.js';
import { getPaths, loadAuth } from './lib/config.js';
import { makeGiteaClient } from './lib/gitea.js';
import { parseStatusMd } from './lib/status-md.js';
import { runSync } from './lib/sync-runner.js';

async function main(): Promise<void> {
  const paths = getPaths(homedir());
  const auth = await loadAuth(paths.authFile).catch((err) => {
    console.error(`auth.toml load failed: ${(err as Error).message}`);
    console.error(`Expected at: ${paths.authFile}`);
    process.exit(2);
  });

  const client = makeGiteaClient({ baseUrl: auth.giteaUrl, token: auth.giteaToken });
  const cache = await runSync({
    client,
    parseStatus: parseStatusMd,
    now: () => new Date(),
    machine: hostname(),
    giteaUrl: auth.giteaUrl,
    user: auth.giteaUser,
  });

  await writeCache(paths.cacheFile, cache);

  await mkdir(dirname(paths.syncLog), { recursive: true });
  const line =
    `[${cache.synced_at}] projects=${cache.projects.length} errors=${cache.errors.length}` +
    (cache.errors.length ? ` (${cache.errors.map((e) => e.project).join(',')})` : '') +
    '\n';
  await appendFile(paths.syncLog, line);

  console.log(`synced ${cache.projects.length} projects, ${cache.errors.length} errors`);
}

await main();
  • Step 2: Build + verify it compiles

Run: npm run build Expected: no errors. dist/sync.js exists.

  • Step 3: Run typecheck

Run: npm run typecheck Expected: no errors.

  • Step 4: Commit
git add src/sync.ts
git commit -m "feat(sync): CLI entry wiring config + runner + cache + log"

Phase 3 — MCP server core

Task 3.1: Server bootstrap (stdio + tool registry)

Files:

  • Modify: src/server.ts (replace skeleton)

The MCP SDK exposes Server and StdioServerTransport. Tools are registered in handlers. The server reads process.argv[2] if given as --cwd <path> for explicit cwd-override; otherwise uses process.cwd().

  • Step 1: Replace src/server.ts
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
  CallToolRequestSchema,
  ListToolsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
import { homedir } from 'node:os';
import { getPaths } from './lib/config.js';
import { makeTasksTools } from './tools/tasks.js';
import { makeKnowledgeTools } from './tools/knowledge.js';
import { makeMetaTools } from './tools/meta.js';

function getCwdArg(): string {
  const i = process.argv.indexOf('--cwd');
  if (i >= 0 && process.argv[i + 1]) return process.argv[i + 1];
  return process.cwd();
}

async function main(): Promise<void> {
  const paths = getPaths(homedir());
  const cwd = getCwdArg();

  const tools = [
    ...makeTasksTools({ cacheFile: paths.cacheFile }),
    ...makeKnowledgeTools({ wikiRoot: paths.sharedWikiClone, projectCwd: cwd }),
    ...makeMetaTools({ paths }),
  ];

  const byName = new Map(tools.map((t) => [t.name, t]));

  const server = new Server(
    { name: 'projects-meta-mcp', version: '0.1.0' },
    { capabilities: { tools: {} } },
  );

  server.setRequestHandler(ListToolsRequestSchema, async () => ({
    tools: tools.map((t) => ({
      name: t.name,
      description: t.description,
      inputSchema: t.inputSchema,
    })),
  }));

  server.setRequestHandler(CallToolRequestSchema, async (req) => {
    const tool = byName.get(req.params.name);
    if (!tool) {
      return {
        content: [{ type: 'text', text: `Unknown tool: ${req.params.name}` }],
        isError: true,
      };
    }
    return await tool.handler(req.params.arguments ?? {});
  });

  const transport = new StdioServerTransport();
  await server.connect(transport);
}

await main();
  • Step 2: Define ToolDef shape

Create src/tools/types.ts:

export interface ToolResult {
  content: Array<{ type: 'text'; text: string }>;
  isError?: boolean;
}

export interface ToolDef {
  name: string;
  description: string;
  inputSchema: {
    type: 'object';
    properties?: Record<string, unknown>;
    required?: string[];
    additionalProperties?: boolean;
  };
  handler: (args: Record<string, unknown>) => Promise<ToolResult>;
}
  • Step 3: Stub the three tool factories so build passes

Create empty stubs (real impl in later tasks). Each returns [].

src/tools/tasks.ts:

import type { ToolDef } from './types.js';

export function makeTasksTools(_opts: { cacheFile: string }): ToolDef[] {
  return [];
}

src/tools/knowledge.ts:

import type { ToolDef } from './types.js';

export function makeKnowledgeTools(_opts: {
  wikiRoot: string;
  projectCwd: string;
}): ToolDef[] {
  return [];
}

src/tools/meta.ts:

import type { ToolDef } from './types.js';
import type { Paths } from '../lib/config.js';

export function makeMetaTools(_opts: { paths: Paths }): ToolDef[] {
  return [];
}
  • Step 4: Build

Run: npm run build Expected: no errors.

  • Step 5: Commit
git add src/server.ts src/tools/types.ts src/tools/tasks.ts src/tools/knowledge.ts src/tools/meta.ts
git commit -m "feat(server): stdio bootstrap + tool registry skeleton"

Task 3.2: Tasks tools (tasks.aggregate, tasks.search, tasks.get)

Files:

  • Modify: src/tools/tasks.ts
  • Create: tests/tools/tasks.test.ts

Contracts:

  • tasks.aggregate({ status?, project? }) → list of { project, slug, status, description, next } filtered by both. No raw text.

  • tasks.search({ query }) → 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:

import { describe, it, expect, beforeEach } from 'vitest';
import { mkdtemp } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { writeCache, type CacheFile } from '../../src/lib/cache.js';
import { makeTasksTools } from '../../src/tools/tasks.js';

const fixture: CacheFile = {
  synced_at: '2026-04-29T12:00:00.000Z',
  synced_from: 'https://g',
  machine: 'm',
  projects: [
    {
      name: 'alpha',
      default_branch: 'main',
      fetched_at: '2026-04-29T12:00:01.000Z',
      active_tasks: [
        { slug: 'auth-rewrite', status: 'active', next: 'add tests' },
        { slug: 'docs', status: 'paused', next: 'screencast' },
      ],
      all_tasks_count: 2,
      raw: '# Task Board\n## 🔴 [auth-rewrite] — Auth\n',
    },
    {
      name: 'beta',
      default_branch: 'main',
      fetched_at: '2026-04-29T12:00:02.000Z',
      active_tasks: [{ slug: 'login', status: 'active', next: 'fix bug' }],
      all_tasks_count: 1,
      raw: '# Task Board\n## 🔴 [login] — Login\n',
    },
  ],
  errors: [],
};

let cacheFile: string;

beforeEach(async () => {
  const dir = await mkdtemp(join(tmpdir(), 'tasks-tools-'));
  cacheFile = join(dir, 'tasks.json');
  await writeCache(cacheFile, fixture);
});

function call(tools: ReturnType<typeof makeTasksTools>, name: string, args: Record<string, unknown>) {
  const t = tools.find((x) => x.name === name);
  if (!t) throw new Error(`tool ${name} not registered`);
  return t.handler(args);
}

describe('tasks.aggregate', () => {
  it('lists everything by default', async () => {
    const tools = makeTasksTools({ cacheFile });
    const r = await call(tools, 'tasks.aggregate', {});
    const parsed = JSON.parse(r.content[0].text);
    expect(parsed.tasks).toHaveLength(3);
    expect(parsed.tasks[0]).toEqual({
      project: 'alpha',
      slug: 'auth-rewrite',
      status: 'active',
      next: 'add tests',
    });
  });

  it('filters by status', async () => {
    const tools = makeTasksTools({ cacheFile });
    const r = await call(tools, 'tasks.aggregate', { status: 'active' });
    const parsed = JSON.parse(r.content[0].text);
    expect(parsed.tasks.map((t: { slug: string }) => t.slug)).toEqual(['auth-rewrite', 'login']);
  });

  it('filters by project', async () => {
    const tools = makeTasksTools({ cacheFile });
    const r = await call(tools, 'tasks.aggregate', { project: 'beta' });
    const parsed = JSON.parse(r.content[0].text);
    expect(parsed.tasks.map((t: { slug: string }) => t.slug)).toEqual(['login']);
  });

  it('returns empty when cache missing', async () => {
    const tools = makeTasksTools({ cacheFile: '/nonexistent/tasks.json' });
    const r = await call(tools, 'tasks.aggregate', {});
    const parsed = JSON.parse(r.content[0].text);
    expect(parsed.tasks).toEqual([]);
    expect(parsed.cache_missing).toBe(true);
  });
});

describe('tasks.search', () => {
  it('matches case-insensitive substring on slug/next', async () => {
    const tools = makeTasksTools({ cacheFile });
    const r = await call(tools, 'tasks.search', { query: 'AUTH' });
    const parsed = JSON.parse(r.content[0].text);
    expect(parsed.tasks.map((t: { slug: string }) => t.slug)).toEqual(['auth-rewrite']);
  });

  it('matches on next-action text', async () => {
    const tools = makeTasksTools({ cacheFile });
    const r = await call(tools, 'tasks.search', { query: 'screencast' });
    const parsed = JSON.parse(r.content[0].text);
    expect(parsed.tasks.map((t: { slug: string }) => t.slug)).toEqual(['docs']);
  });
});

describe('tasks.get', () => {
  it('returns full raw STATUS.md', async () => {
    const tools = makeTasksTools({ cacheFile });
    const r = await call(tools, 'tasks.get', { project: 'alpha' });
    expect(r.content[0].text).toContain('[auth-rewrite]');
  });

  it('returns error for unknown project', async () => {
    const tools = makeTasksTools({ cacheFile });
    const r = await call(tools, 'tasks.get', { project: 'ghost' });
    expect(r.isError).toBe(true);
  });
});
  • Step 2: Run, expect FAIL

Run: npm test -- tests/tools/tasks.test.ts Expected: FAIL.

  • Step 3: Implement src/tools/tasks.ts
import { z } from 'zod';
import { readCache } from '../lib/cache.js';
import type { ToolDef, ToolResult } from './types.js';

interface Opts {
  cacheFile: string;
}

const StatusEnum = z.enum(['active', 'paused', 'blocked', 'ready', 'done']);

const AggregateInput = z.object({
  status: StatusEnum.optional(),
  project: z.string().optional(),
});

const SearchInput = z.object({
  query: z.string().min(1),
});

const GetInput = z.object({
  project: z.string().min(1),
});

function asJson(value: unknown): ToolResult {
  return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] };
}

function asError(msg: string): ToolResult {
  return { content: [{ type: 'text', text: msg }], isError: true };
}

export function makeTasksTools(opts: Opts): ToolDef[] {
  return [
    {
      name: 'tasks.aggregate',
      description:
        'Свод активных задач (active/paused/blocked) по всем проектам. Фильтры по статусу и имени проекта. Не возвращает полный текст STATUS.md.',
      inputSchema: {
        type: 'object',
        properties: {
          status: { type: 'string', enum: ['active', 'paused', 'blocked', 'ready', 'done'] },
          project: { type: 'string' },
        },
        additionalProperties: false,
      },
      async handler(args) {
        const input = AggregateInput.parse(args);
        const cache = await readCache(opts.cacheFile);
        if (!cache) return asJson({ tasks: [], cache_missing: true });

        const out: Array<{ project: string; slug: string; status: string; next: string | null }> = [];
        for (const p of cache.projects) {
          if (input.project && p.name !== input.project) continue;
          for (const t of p.active_tasks) {
            if (input.status && t.status !== input.status) continue;
            out.push({ project: p.name, slug: t.slug, status: t.status, next: t.next });
          }
        }
        return asJson({ tasks: out, synced_at: cache.synced_at });
      },
    },
    {
      name: 'tasks.search',
      description:
        'Подстрочный регистронезависимый поиск по slug / next-action всех задач в кэше. Возвращает то же что tasks.aggregate.',
      inputSchema: {
        type: 'object',
        properties: { query: { type: 'string' } },
        required: ['query'],
        additionalProperties: false,
      },
      async handler(args) {
        const input = SearchInput.parse(args);
        const cache = await readCache(opts.cacheFile);
        if (!cache) return asJson({ tasks: [], cache_missing: true });

        const q = input.query.toLowerCase();
        const out: Array<{ project: string; slug: string; status: string; next: string | null }> = [];
        for (const p of cache.projects) {
          for (const t of p.active_tasks) {
            const hay = `${t.slug} ${t.next ?? ''}`.toLowerCase();
            if (hay.includes(q)) {
              out.push({ project: p.name, slug: t.slug, status: t.status, next: t.next });
            }
          }
        }
        return asJson({ tasks: out, synced_at: cache.synced_at });
      },
    },
    {
      name: 'tasks.get',
      description: 'Полный raw STATUS.md одного проекта (как было получено при последнем sync).',
      inputSchema: {
        type: 'object',
        properties: { project: { type: 'string' } },
        required: ['project'],
        additionalProperties: false,
      },
      async handler(args) {
        const input = GetInput.parse(args);
        const cache = await readCache(opts.cacheFile);
        if (!cache) return asError('cache missing — run sync first');
        const p = cache.projects.find((x) => x.name === input.project);
        if (!p) return asError(`project not found: ${input.project}`);
        return { content: [{ type: 'text', text: p.raw }] };
      },
    },
  ];
}
  • Step 4: Run, expect PASS

Run: npm test -- tests/tools/tasks.test.ts Expected: 8 passed.

  • Step 5: Commit
git add src/tools/tasks.ts tests/tools/tasks.test.ts
git commit -m "feat(tools): tasks.aggregate/search/get"

Task 3.3: Domain detector

Files:

  • Create: src/lib/domain-detector.ts
  • Create: tests/lib/domain-detector.test.ts

Contract:

export type Domain = 'node' | 'embedded' | 'web' | 'unknown';
export async function detectDomain(cwd: string): Promise<Domain>;

Decision rules (spec §detect domain):

  1. package.json exists → node
  2. platformio.ini OR any *.ino OR CMakeLists.txt containing arm-none-eabi or STM32 or ESP_IDFembedded
  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:

import { describe, it, expect } from 'vitest';
import { mkdtemp, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { detectDomain } from '../../src/lib/domain-detector.js';

async function tmp(): Promise<string> {
  return mkdtemp(join(tmpdir(), 'dd-'));
}

describe('detectDomain', () => {
  it('returns node when package.json present', async () => {
    const d = await tmp();
    await writeFile(join(d, 'package.json'), '{}');
    expect(await detectDomain(d)).toBe('node');
  });

  it('returns embedded for platformio.ini', async () => {
    const d = await tmp();
    await writeFile(join(d, 'platformio.ini'), '[env]');
    expect(await detectDomain(d)).toBe('embedded');
  });

  it('returns embedded for .ino', async () => {
    const d = await tmp();
    await writeFile(join(d, 'sketch.ino'), 'void setup() {}');
    expect(await detectDomain(d)).toBe('embedded');
  });

  it('returns embedded for ARM CMakeLists', async () => {
    const d = await tmp();
    await writeFile(join(d, 'CMakeLists.txt'), 'set(CMAKE_C_COMPILER arm-none-eabi-gcc)');
    expect(await detectDomain(d)).toBe('embedded');
  });

  it('returns web for vite config', async () => {
    const d = await tmp();
    await writeFile(join(d, 'vite.config.ts'), 'export default {}');
    expect(await detectDomain(d)).toBe('web');
  });

  it('returns web for static index.html without package.json', async () => {
    const d = await tmp();
    await writeFile(join(d, 'index.html'), '<html></html>');
    expect(await detectDomain(d)).toBe('web');
  });

  it('returns unknown for empty dir', async () => {
    const d = await tmp();
    expect(await detectDomain(d)).toBe('unknown');
  });

  it('package.json wins over index.html', async () => {
    const d = await tmp();
    await writeFile(join(d, 'package.json'), '{}');
    await writeFile(join(d, 'index.html'), '<html></html>');
    expect(await detectDomain(d)).toBe('node');
  });
});
  • Step 2: Run, expect FAIL

Run: npm test -- tests/lib/domain-detector.test.ts Expected: FAIL.

  • Step 3: Implement src/lib/domain-detector.ts
import { readdir, readFile, stat } from 'node:fs/promises';
import { join } from 'node:path';

export type Domain = 'node' | 'embedded' | 'web' | 'unknown';

const EMBEDDED_NEEDLES = ['arm-none-eabi', 'STM32', 'ESP_IDF', 'STM32CubeMX'];

async function exists(p: string): Promise<boolean> {
  try {
    await stat(p);
    return true;
  } catch {
    return false;
  }
}

async function listFiles(dir: string): Promise<string[]> {
  try {
    const entries = await readdir(dir, { withFileTypes: true });
    return entries.filter((e) => e.isFile()).map((e) => e.name);
  } catch {
    return [];
  }
}

export async function detectDomain(cwd: string): Promise<Domain> {
  const pkgJson = await exists(join(cwd, 'package.json'));
  if (pkgJson) return 'node';

  if (await exists(join(cwd, 'platformio.ini'))) return 'embedded';

  const files = await listFiles(cwd);
  if (files.some((f) => f.endsWith('.ino'))) return 'embedded';

  if (files.includes('CMakeLists.txt')) {
    const txt = await readFile(join(cwd, 'CMakeLists.txt'), 'utf8').catch(() => '');
    if (EMBEDDED_NEEDLES.some((n) => txt.includes(n))) return 'embedded';
  }

  if (files.some((f) => /^next\.config\.(js|mjs|cjs|ts)$/.test(f))) return 'web';
  if (files.some((f) => /^vite\.config\.(js|mjs|cjs|ts)$/.test(f))) return 'web';
  if (files.includes('index.html')) return 'web';

  return 'unknown';
}
  • Step 4: Run, expect PASS

Run: npm test -- tests/lib/domain-detector.test.ts Expected: 8 passed.

  • Step 5: Commit
git add src/lib/domain-detector.ts tests/lib/domain-detector.test.ts
git commit -m "feat(domain): node/embedded/web detector by cwd files"

Task 3.4: Knowledge tools (knowledge.search, knowledge.get)

Files:

  • Create: src/lib/wiki-index.ts
  • Modify: src/tools/knowledge.ts
  • Create: tests/lib/wiki-index.test.ts
  • Create: tests/tools/knowledge.test.ts

Contract for wiki-index:

export interface WikiPage {
  slug: string;          // path relative to wikiRoot, no .md extension, e.g. "node/windows-yarn"
  title: string;
  domain: string;        // from frontmatter
  tags: string[];
  body: string;          // body text only (no frontmatter)
  raw: string;           // full file contents
}

export async function loadWiki(root: string): Promise<WikiPage[]>;

Walk all *.md files except CLAUDE.md and README.md at root level (those are wiki meta). Skip pages without domain: frontmatter (warn but include with domain="unknown").

Contract for knowledge tools:

  • knowledge.search({ query, domain?, limit? }) → list of { slug, title, snippet, domain }. 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:

import { describe, it, expect, beforeEach } from 'vitest';
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { loadWiki } from '../../src/lib/wiki-index.js';

let root: string;

beforeEach(async () => {
  root = await mkdtemp(join(tmpdir(), 'wiki-'));
  await mkdir(join(root, 'node'), { recursive: true });
  await mkdir(join(root, 'cross'), { recursive: true });

  await writeFile(join(root, 'CLAUDE.md'), 'should be skipped');
  await writeFile(join(root, 'README.md'), 'should be skipped');

  await writeFile(
    join(root, 'node', 'windows-yarn-exec.md'),
    [
      '---',
      'title: Windows yarn requires exec()',
      'domain: node',
      'tags: [windows, yarn]',
      '---',
      '',
      '## Why',
      'Windows is special.',
    ].join('\n'),
  );

  await writeFile(
    join(root, 'cross', 'git-tips.md'),
    ['---', 'title: Git tips', 'domain: cross', 'tags: [git]', '---', '', 'body'].join('\n'),
  );

  await writeFile(
    join(root, 'cross', 'no-fm.md'),
    'just a body, no frontmatter',
  );
});

describe('loadWiki', () => {
  it('skips top-level meta files', async () => {
    const pages = await loadWiki(root);
    expect(pages.find((p) => p.slug === 'CLAUDE')).toBeUndefined();
    expect(pages.find((p) => p.slug === 'README')).toBeUndefined();
  });

  it('loads pages with frontmatter', async () => {
    const pages = await loadWiki(root);
    const yarn = pages.find((p) => p.slug === 'node/windows-yarn-exec');
    expect(yarn).toBeDefined();
    expect(yarn!.title).toBe('Windows yarn requires exec()');
    expect(yarn!.domain).toBe('node');
    expect(yarn!.tags).toEqual(['windows', 'yarn']);
    expect(yarn!.body).toContain('Windows is special.');
  });

  it('marks frontmatterless pages as unknown domain', async () => {
    const pages = await loadWiki(root);
    const fm = pages.find((p) => p.slug === 'cross/no-fm');
    expect(fm).toBeDefined();
    expect(fm!.domain).toBe('unknown');
  });

  it('returns empty array if root missing', async () => {
    const pages = await loadWiki(join(root, 'does-not-exist'));
    expect(pages).toEqual([]);
  });
});
  • Step 2: Run, expect FAIL

Run: npm test -- tests/lib/wiki-index.test.ts Expected: FAIL.

  • Step 3: Implement src/lib/wiki-index.ts
import { readdir, readFile, stat } from 'node:fs/promises';
import { join, relative, sep } from 'node:path';
import matter from 'gray-matter';

export interface WikiPage {
  slug: string;
  title: string;
  domain: string;
  tags: string[];
  body: string;
  raw: string;
}

const META_TOP = new Set(['CLAUDE.md', 'README.md', 'index.md', 'log.md', 'overview.md']);

async function walk(root: string, prefix = ''): Promise<string[]> {
  const out: string[] = [];
  let entries;
  try {
    entries = await readdir(root, { withFileTypes: true });
  } catch {
    return [];
  }
  for (const e of entries) {
    const rel = prefix ? `${prefix}/${e.name}` : e.name;
    if (e.isDirectory()) {
      if (e.name.startsWith('.')) continue;
      const nested = await walk(join(root, e.name), rel);
      out.push(...nested);
    } else if (e.isFile() && e.name.endsWith('.md')) {
      if (prefix === '' && META_TOP.has(e.name)) continue;
      out.push(rel);
    }
  }
  return out;
}

export async function loadWiki(root: string): Promise<WikiPage[]> {
  try {
    const s = await stat(root);
    if (!s.isDirectory()) return [];
  } catch {
    return [];
  }

  const relPaths = await walk(root);
  const pages: WikiPage[] = [];

  for (const rel of relPaths) {
    const abs = join(root, ...rel.split('/'));
    const raw = await readFile(abs, 'utf8');
    const slug = rel.replace(/\.md$/, '').split(sep).join('/');
    const parsed = matter(raw);
    const fm = parsed.data as Record<string, unknown>;

    pages.push({
      slug,
      title: typeof fm.title === 'string' ? fm.title : slug,
      domain: typeof fm.domain === 'string' ? fm.domain : 'unknown',
      tags: Array.isArray(fm.tags) ? (fm.tags as unknown[]).filter((t): t is string => typeof t === 'string') : [],
      body: parsed.content,
      raw,
    });
  }

  return pages;
}
  • Step 4: Run wiki-index tests, expect PASS

Run: npm test -- tests/lib/wiki-index.test.ts Expected: 4 passed.

  • Step 5: Commit wiki-index
git add src/lib/wiki-index.ts tests/lib/wiki-index.test.ts
git commit -m "feat(wiki): page loader with frontmatter parse"
  • Step 6: Write failing knowledge-tools tests

tests/tools/knowledge.test.ts:

import { describe, it, expect, beforeEach } from 'vitest';
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { makeKnowledgeTools } from '../../src/tools/knowledge.js';

let wikiRoot: string;
let nodeProject: string;
let unknownProject: string;

beforeEach(async () => {
  wikiRoot = await mkdtemp(join(tmpdir(), 'kw-wiki-'));
  await mkdir(join(wikiRoot, 'node'), { recursive: true });
  await mkdir(join(wikiRoot, 'embedded'), { recursive: true });
  await mkdir(join(wikiRoot, 'cross'), { recursive: true });

  await writeFile(
    join(wikiRoot, 'node', 'yarn-windows.md'),
    '---\ntitle: Yarn on Windows\ndomain: node\n---\n\nyarn body',
  );
  await writeFile(
    join(wikiRoot, 'embedded', 'stm32-flash.md'),
    '---\ntitle: STM32 Flashing\ndomain: embedded\n---\n\nstm32 body',
  );
  await writeFile(
    join(wikiRoot, 'cross', 'git-trick.md'),
    '---\ntitle: Git Trick\ndomain: cross\n---\n\ngit yarn body',
  );

  nodeProject = await mkdtemp(join(tmpdir(), 'kw-proj-'));
  await writeFile(join(nodeProject, 'package.json'), '{}');

  unknownProject = await mkdtemp(join(tmpdir(), 'kw-proj-'));
});

function call(tools: ReturnType<typeof makeKnowledgeTools>, name: string, args: Record<string, unknown>) {
  const t = tools.find((x) => x.name === name);
  if (!t) throw new Error(`tool ${name} missing`);
  return t.handler(args);
}

describe('knowledge.search (node project)', () => {
  it('shows node + cross by default, hides embedded', async () => {
    const tools = makeKnowledgeTools({ wikiRoot, projectCwd: nodeProject });
    const r = await call(tools, 'knowledge.search', { query: 'yarn' });
    const parsed = JSON.parse(r.content[0].text);
    const slugs = parsed.results.map((x: { slug: string }) => x.slug).sort();
    expect(slugs).toEqual(['cross/git-trick', 'node/yarn-windows']);
  });

  it('domain="all" shows everything', async () => {
    const tools = makeKnowledgeTools({ wikiRoot, projectCwd: nodeProject });
    const r = await call(tools, 'knowledge.search', { query: '', domain: 'all' });
    const parsed = JSON.parse(r.content[0].text);
    expect(parsed.results.length).toBe(3);
  });

  it('explicit domain switches filter', async () => {
    const tools = makeKnowledgeTools({ wikiRoot, projectCwd: nodeProject });
    const r = await call(tools, 'knowledge.search', { query: '', domain: 'embedded' });
    const parsed = JSON.parse(r.content[0].text);
    const slugs = parsed.results.map((x: { slug: string }) => x.slug).sort();
    expect(slugs).toEqual(['cross/git-trick', 'embedded/stm32-flash']);
  });
});

describe('knowledge.search (unknown project)', () => {
  it('returns all when cwd domain unknown', async () => {
    const tools = makeKnowledgeTools({ wikiRoot, projectCwd: unknownProject });
    const r = await call(tools, 'knowledge.search', { query: '' });
    const parsed = JSON.parse(r.content[0].text);
    expect(parsed.results.length).toBe(3);
  });
});

describe('knowledge.get', () => {
  it('returns full page including frontmatter', async () => {
    const tools = makeKnowledgeTools({ wikiRoot, projectCwd: nodeProject });
    const r = await call(tools, 'knowledge.get', { slug: 'node/yarn-windows' });
    expect(r.content[0].text).toContain('domain: node');
    expect(r.content[0].text).toContain('yarn body');
  });

  it('errors on missing slug', async () => {
    const tools = makeKnowledgeTools({ wikiRoot, projectCwd: nodeProject });
    const r = await call(tools, 'knowledge.get', { slug: 'ghost/page' });
    expect(r.isError).toBe(true);
  });
});
  • Step 7: Run, expect FAIL

Run: npm test -- tests/tools/knowledge.test.ts Expected: FAIL.

  • Step 8: Implement src/tools/knowledge.ts
import { z } from 'zod';
import { detectDomain, type Domain } from '../lib/domain-detector.js';
import { loadWiki, type WikiPage } from '../lib/wiki-index.js';
import type { ToolDef, ToolResult } from './types.js';

interface Opts {
  wikiRoot: string;
  projectCwd: string;
}

const SearchInput = z.object({
  query: z.string(),
  domain: z.string().optional(),
  limit: z.number().int().positive().max(50).optional(),
});

const GetInput = z.object({
  slug: z.string().min(1),
});

function asJson(value: unknown): ToolResult {
  return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] };
}

function asError(msg: string): ToolResult {
  return { content: [{ type: 'text', text: msg }], isError: true };
}

function snippet(body: string, query: string, span = 80): string {
  const trimmed = body.replace(/\s+/g, ' ').trim();
  if (!query) return trimmed.slice(0, span);
  const idx = trimmed.toLowerCase().indexOf(query.toLowerCase());
  if (idx < 0) return trimmed.slice(0, span);
  const start = Math.max(0, idx - 20);
  return trimmed.slice(start, start + span);
}

function pageMatches(p: WikiPage, query: string): boolean {
  if (!query) return true;
  const q = query.toLowerCase();
  return (
    p.title.toLowerCase().includes(q) ||
    p.body.toLowerCase().includes(q) ||
    p.tags.some((t) => t.toLowerCase().includes(q))
  );
}

function domainAllows(pageDomain: string, filter: string): boolean {
  if (filter === 'all') return true;
  return pageDomain === filter || pageDomain === 'cross';
}

export function makeKnowledgeTools(opts: Opts): ToolDef[] {
  let cached: WikiPage[] | null = null;
  async function pages(): Promise<WikiPage[]> {
    if (cached) return cached;
    cached = await loadWiki(opts.wikiRoot);
    return cached;
  }

  let detectedDomain: Domain | null = null;
  async function domain(): Promise<Domain> {
    if (detectedDomain) return detectedDomain;
    detectedDomain = await detectDomain(opts.projectCwd);
    return detectedDomain;
  }

  return [
    {
      name: 'knowledge.search',
      description:
        'Поиск по shared-вики (репо projects-wiki). По умолчанию фильтр по domain соответствует cwd текущего проекта (node/embedded/web). Передай domain="all" чтобы снять фильтр или явное имя домена. limit по умолчанию 10. Возвращает только заголовок + сниппет; полный текст — knowledge.get.',
      inputSchema: {
        type: 'object',
        properties: {
          query: { type: 'string' },
          domain: { type: 'string' },
          limit: { type: 'integer', minimum: 1, maximum: 50 },
        },
        required: ['query'],
        additionalProperties: false,
      },
      async handler(args) {
        const input = SearchInput.parse(args);
        const all = await pages();
        const det = await domain();
        const filter = input.domain ?? (det === 'unknown' ? 'all' : det);
        const limit = input.limit ?? 10;

        const matched = all
          .filter((p) => domainAllows(p.domain, filter))
          .filter((p) => pageMatches(p, input.query))
          .slice(0, limit)
          .map((p) => ({
            slug: p.slug,
            title: p.title,
            domain: p.domain,
            snippet: snippet(p.body, input.query),
          }));

        return asJson({
          detected_domain: det,
          applied_filter: filter,
          results: matched,
        });
      },
    },
    {
      name: 'knowledge.get',
      description: 'Полный текст одной страницы projects-wiki по её slug (например "node/windows-yarn-exec").',
      inputSchema: {
        type: 'object',
        properties: { slug: { type: 'string' } },
        required: ['slug'],
        additionalProperties: false,
      },
      async handler(args) {
        const input = GetInput.parse(args);
        const all = await pages();
        const page = all.find((p) => p.slug === input.slug);
        if (!page) return asError(`page not found: ${input.slug}`);
        return { content: [{ type: 'text', text: page.raw }] };
      },
    },
  ];
}
  • Step 9: Run, expect PASS

Run: npm test -- tests/tools/knowledge.test.ts Expected: 6 passed.

  • Step 10: Commit
git add src/tools/knowledge.ts tests/tools/knowledge.test.ts
git commit -m "feat(tools): knowledge.search/get with domain filter"

Task 3.5: Meta tools (meta.status)

Files:

  • Modify: src/tools/meta.ts
  • Create: tests/tools/meta.test.ts

Contract:

meta.status() → { synced_at, age_seconds, projects_count, errors_count, cache_path, wiki_root, wiki_pages_count, stale: boolean }

Stale threshold: 24 hours (age_seconds > 86400).

  • Step 1: Write failing tests

tests/tools/meta.test.ts:

import { describe, it, expect, beforeEach } from 'vitest';
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { writeCache } from '../../src/lib/cache.js';
import { makeMetaTools } from '../../src/tools/meta.js';
import type { Paths } from '../../src/lib/config.js';

function mkPaths(home: string): Paths {
  const cacheDir = join(home, '.cache');
  return {
    cacheDir,
    cacheFile: join(cacheDir, 'tasks.json'),
    syncLog: join(cacheDir, 'sync.log'),
    authFile: join(home, '.config', 'auth.toml'),
    sharedWikiClone: join(home, 'wiki'),
  };
}

describe('meta.status', () => {
  let paths: Paths;

  beforeEach(async () => {
    const home = await mkdtemp(join(tmpdir(), 'meta-'));
    paths = mkPaths(home);
    await mkdir(paths.sharedWikiClone, { recursive: true });
    await mkdir(join(paths.sharedWikiClone, 'node'), { recursive: true });
    await writeFile(
      join(paths.sharedWikiClone, 'node', 'one.md'),
      '---\ntitle: t\ndomain: node\n---\n\nbody',
    );
  });

  it('reports cache_missing when no cache', async () => {
    const tools = makeMetaTools({ paths });
    const r = await (tools.find((t) => t.name === 'meta.status')!.handler({}));
    const parsed = JSON.parse(r.content[0].text);
    expect(parsed.cache_missing).toBe(true);
    expect(parsed.wiki_pages_count).toBe(1);
  });

  it('reports age and stale=false for fresh cache', async () => {
    await writeCache(paths.cacheFile, {
      synced_at: new Date().toISOString(),
      synced_from: 'https://g',
      machine: 'm',
      projects: [],
      errors: [],
    });
    const tools = makeMetaTools({ paths });
    const r = await (tools.find((t) => t.name === 'meta.status')!.handler({}));
    const parsed = JSON.parse(r.content[0].text);
    expect(parsed.stale).toBe(false);
    expect(parsed.age_seconds).toBeGreaterThanOrEqual(0);
  });

  it('reports stale=true for >24h cache', async () => {
    const old = new Date(Date.now() - 25 * 3600 * 1000).toISOString();
    await writeCache(paths.cacheFile, {
      synced_at: old,
      synced_from: 'https://g',
      machine: 'm',
      projects: [{ name: 'p', default_branch: 'main', fetched_at: old, active_tasks: [], all_tasks_count: 0, raw: '' }],
      errors: [{ project: 'q', reason: 'x' }],
    });
    const tools = makeMetaTools({ paths });
    const r = await (tools.find((t) => t.name === 'meta.status')!.handler({}));
    const parsed = JSON.parse(r.content[0].text);
    expect(parsed.stale).toBe(true);
    expect(parsed.projects_count).toBe(1);
    expect(parsed.errors_count).toBe(1);
  });
});
  • Step 2: Run, expect FAIL

Run: npm test -- tests/tools/meta.test.ts Expected: FAIL.

  • Step 3: Implement src/tools/meta.ts
import { readCache } from '../lib/cache.js';
import type { Paths } from '../lib/config.js';
import { loadWiki } from '../lib/wiki-index.js';
import type { ToolDef, ToolResult } from './types.js';

const STALE_AFTER_SEC = 24 * 3600;

interface Opts {
  paths: Paths;
}

function asJson(value: unknown): ToolResult {
  return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] };
}

export function makeMetaTools(opts: Opts): ToolDef[] {
  return [
    {
      name: 'meta.status',
      description:
        'Диагностика: возраст кэша, количество проектов / ошибок / страниц wiki. Используй когда нужно понять, свежие ли данные.',
      inputSchema: { type: 'object', properties: {}, additionalProperties: false },
      async handler() {
        const cache = await readCache(opts.paths.cacheFile);
        const wiki = await loadWiki(opts.paths.sharedWikiClone);
        if (!cache) {
          return asJson({
            cache_missing: true,
            cache_path: opts.paths.cacheFile,
            wiki_root: opts.paths.sharedWikiClone,
            wiki_pages_count: wiki.length,
          });
        }
        const ageMs = Date.now() - new Date(cache.synced_at).getTime();
        const ageSec = Math.floor(ageMs / 1000);
        return asJson({
          synced_at: cache.synced_at,
          age_seconds: ageSec,
          stale: ageSec > STALE_AFTER_SEC,
          projects_count: cache.projects.length,
          errors_count: cache.errors.length,
          cache_path: opts.paths.cacheFile,
          wiki_root: opts.paths.sharedWikiClone,
          wiki_pages_count: wiki.length,
        });
      },
    },
  ];
}
  • Step 4: Run, expect PASS

Run: npm test -- tests/tools/meta.test.ts Expected: 3 passed.

  • Step 5: Commit
git add src/tools/meta.ts tests/tools/meta.test.ts
git commit -m "feat(tools): meta.status with stale threshold"

Task 3.6: Promotion candidate tool (knowledge.suggest_promote)

Files:

  • Create: src/lib/promotion.ts
  • Create: tests/lib/promotion.test.ts
  • Modify: src/tools/knowledge.ts (add tool)
  • Modify: tests/tools/knowledge.test.ts (extend tests)

Heuristic (per spec §knowledge.suggest_promote):

  • INCLUDE if page mentions any platform/tooling keyword: Windows, Linux, macOS, yarn, npm, Node, MCP, git, Docker, tsx, vite, webpack, pnpm.
  • EXCLUDE if page mentions any project name from a configured blacklist (passed in opts).
  • BOOST confidence if projects-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:

export interface PromotionCandidate {
  slug: string;
  current_path: string;
  suggested_domain: string;
  confidence: 'low' | 'medium' | 'high';
  reason: string;
}

export interface PromotionDeps {
  projectWikiDir: string;       // <cwd>/.wiki
  sharedPages: WikiPage[];      // already loaded
  cwdDomain: Domain;
  blacklist: string[];          // project / domain names
}

export async function findCandidates(deps: PromotionDeps): Promise<PromotionCandidate[]>;
  • Step 1: Write failing promotion tests

tests/lib/promotion.test.ts:

import { describe, it, expect, beforeEach } from 'vitest';
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { findCandidates } from '../../src/lib/promotion.js';
import type { WikiPage } from '../../src/lib/wiki-index.js';

let projectWiki: string;

beforeEach(async () => {
  const root = await mkdtemp(join(tmpdir(), 'promo-'));
  projectWiki = join(root, '.wiki');
  await mkdir(join(projectWiki, 'concepts'), { recursive: true });
});

const sharedPages: WikiPage[] = [
  { slug: 'embedded/yarn-on-windows', title: 'Yarn on Windows', domain: 'embedded', tags: [], body: '', raw: '' },
];

describe('findCandidates', () => {
  it('includes platform-flavored concept', async () => {
    await writeFile(
      join(projectWiki, 'concepts', 'windows-yarn-quirk.md'),
      '---\ntitle: Windows yarn quirk\n---\n\nWhen yarn on Windows, exec() is required.',
    );
    const r = await findCandidates({
      projectWikiDir: projectWiki,
      sharedPages,
      cwdDomain: 'node',
      blacklist: [],
    });
    expect(r).toHaveLength(1);
    expect(r[0].suggested_domain).toBe('node');
    expect(r[0].confidence).toBe('high'); // platform keyword + similar shared title
  });

  it('excludes when project name in blacklist matched', async () => {
    await writeFile(
      join(projectWiki, 'concepts', 'snolla-edge-case.md'),
      '---\ntitle: Snolla edge case\n---\n\nWhen yarn meets the Snolla pipeline, special handling for npm-mcp.',
    );
    const r = await findCandidates({
      projectWikiDir: projectWiki,
      sharedPages,
      cwdDomain: 'node',
      blacklist: ['snolla', 'npm-mcp'],
    });
    expect(r).toHaveLength(0);
  });

  it('returns confidence=medium when keyword match but no similar shared title', async () => {
    await writeFile(
      join(projectWiki, 'concepts', 'docker-on-linux.md'),
      '---\ntitle: Docker on Linux\n---\n\nDocker daemon socket on Linux.',
    );
    const r = await findCandidates({
      projectWikiDir: projectWiki,
      sharedPages,
      cwdDomain: 'node',
      blacklist: [],
    });
    expect(r).toHaveLength(1);
    expect(r[0].confidence).toBe('medium');
  });

  it('skips concept without platform keyword', async () => {
    await writeFile(
      join(projectWiki, 'concepts', 'business-rule.md'),
      '---\ntitle: Order discount logic\n---\n\nWhen total > 100 apply 5% off.',
    );
    const r = await findCandidates({
      projectWikiDir: projectWiki,
      sharedPages,
      cwdDomain: 'node',
      blacklist: [],
    });
    expect(r).toHaveLength(0);
  });

  it('cwdDomain unknown → suggested_domain=cross', async () => {
    await writeFile(
      join(projectWiki, 'concepts', 'git-trick.md'),
      '---\ntitle: Git tip\n---\n\ngit reflog tip.',
    );
    const r = await findCandidates({
      projectWikiDir: projectWiki,
      sharedPages,
      cwdDomain: 'unknown',
      blacklist: [],
    });
    expect(r[0].suggested_domain).toBe('cross');
  });
});
  • Step 2: Run, expect FAIL

Run: npm test -- tests/lib/promotion.test.ts Expected: FAIL.

  • Step 3: Implement src/lib/promotion.ts
import { readdir, readFile } from 'node:fs/promises';
import { join } from 'node:path';
import matter from 'gray-matter';
import type { Domain } from './domain-detector.js';
import type { WikiPage } from './wiki-index.js';

export interface PromotionCandidate {
  slug: string;
  current_path: string;
  suggested_domain: string;
  confidence: 'low' | 'medium' | 'high';
  reason: string;
}

export interface PromotionDeps {
  projectWikiDir: string;
  sharedPages: WikiPage[];
  cwdDomain: Domain;
  blacklist: string[];
}

const PLATFORM_KEYWORDS = [
  'windows',
  'linux',
  'macos',
  'mac os',
  'yarn',
  'npm',
  'pnpm',
  'node',
  'mcp',
  'git ',
  'docker',
  'tsx',
  'vite',
  'webpack',
  'eslint',
  'tsc',
  'powershell',
  'bash',
];

function tokenize(s: string): Set<string> {
  return new Set(
    s
      .toLowerCase()
      .replace(/[^a-z0-9 ]+/g, ' ')
      .split(/\s+/)
      .filter((w) => w.length >= 3),
  );
}

function jaccard(a: Set<string>, b: Set<string>): number {
  if (a.size === 0 && b.size === 0) return 0;
  let inter = 0;
  for (const x of a) if (b.has(x)) inter += 1;
  const union = a.size + b.size - inter;
  return union === 0 ? 0 : inter / union;
}

async function listConcepts(dir: string): Promise<string[]> {
  try {
    const files = await readdir(dir, { withFileTypes: true });
    return files.filter((f) => f.isFile() && f.name.endsWith('.md')).map((f) => f.name);
  } catch {
    return [];
  }
}

function suggestDomain(d: Domain): string {
  return d === 'unknown' ? 'cross' : d;
}

export async function findCandidates(deps: PromotionDeps): Promise<PromotionCandidate[]> {
  const conceptsDir = join(deps.projectWikiDir, 'concepts');
  const files = await listConcepts(conceptsDir);
  const out: PromotionCandidate[] = [];

  for (const f of files) {
    const path = join(conceptsDir, f);
    const text = await readFile(path, 'utf8');
    const { data: fm, content } = matter(text);
    const title = typeof fm.title === 'string' ? fm.title : f.replace(/\.md$/, '');
    const haystack = `${title}\n${content}`.toLowerCase();

    if (deps.blacklist.some((b) => haystack.includes(b.toLowerCase()))) continue;

    const matchedKw = PLATFORM_KEYWORDS.find((k) => haystack.includes(k));
    if (!matchedKw) continue;

    const titleTokens = tokenize(title);
    const similar = deps.sharedPages.find((p) => jaccard(titleTokens, tokenize(p.title)) >= 0.4);

    const confidence: PromotionCandidate['confidence'] = similar ? 'high' : 'medium';
    const reasonParts = [`keyword: ${matchedKw.trim()}`];
    if (similar) reasonParts.push(`similar shared page: ${similar.slug}`);

    out.push({
      slug: f.replace(/\.md$/, ''),
      current_path: path,
      suggested_domain: suggestDomain(deps.cwdDomain),
      confidence,
      reason: reasonParts.join('; '),
    });
  }
  return out;
}
  • Step 4: Run promotion tests, expect PASS

Run: npm test -- tests/lib/promotion.test.ts Expected: 5 passed.

  • Step 5: Add knowledge.suggest_promote to src/tools/knowledge.ts

Insert this tool after knowledge.get in the returned array of makeKnowledgeTools:

{
  name: 'knowledge.suggest_promote',
  description:
    'Возвращает кандидатов из текущего .wiki/concepts/ на промоушен в projects-wiki. Фильтр по платформенным ключам, исключение по чёрному списку проектов. Решение всегда у пользователя — этот tool не пишет файлы.',
  inputSchema: { type: 'object', properties: {}, additionalProperties: false },
  async handler() {
    const det = await domain();
    const shared = await pages();
    const candidates = await findCandidates({
      projectWikiDir: join(opts.projectCwd, '.wiki'),
      sharedPages: shared,
      cwdDomain: det,
      blacklist: [],
    });
    return asJson({ candidates });
  },
},

Add imports at top of src/tools/knowledge.ts:

import { join } from 'node:path';
import { findCandidates } from '../lib/promotion.js';
  • Step 6: Extend tests/tools/knowledge.test.ts with one suggest_promote test

Append to the file:

import { mkdir as _mkdirFs } from 'node:fs/promises';

describe('knowledge.suggest_promote', () => {
  it('returns candidates from project .wiki/concepts', async () => {
    await _mkdirFs(join(nodeProject, '.wiki', 'concepts'), { recursive: true });
    await writeFile(
      join(nodeProject, '.wiki', 'concepts', 'docker-quirk.md'),
      '---\ntitle: Docker quirk\n---\n\nDocker daemon stuff.',
    );
    const tools = makeKnowledgeTools({ wikiRoot, projectCwd: nodeProject });
    const r = await call(tools, 'knowledge.suggest_promote', {});
    const parsed = JSON.parse(r.content[0].text);
    expect(parsed.candidates).toHaveLength(1);
    expect(parsed.candidates[0].slug).toBe('docker-quirk');
    expect(parsed.candidates[0].suggested_domain).toBe('node');
  });
});
  • Step 7: Run all knowledge tests

Run: npm test -- tests/tools/knowledge.test.ts tests/lib/promotion.test.ts Expected: all pass.

  • Step 8: Commit
git add src/lib/promotion.ts tests/lib/promotion.test.ts src/tools/knowledge.ts tests/tools/knowledge.test.ts
git commit -m "feat(promotion): suggest_promote tool with keyword + similarity heuristic"

Phase 4 — Wiring & smoke

Task 4.1: Build + run all tests

  • Step 1: Full typecheck + build

Run: npm run typecheck && npm run build Expected: zero errors. dist/sync.js and dist/server.js produced.

  • Step 2: Full test suite

Run: npm test Expected: every test from previous tasks PASS, no failures.

  • Step 3: Smoke-run sync without auth (expect graceful failure)

Run: node dist/sync.js Expected: exit code 2 with message auth.toml load failed: ... (auth.toml absent on CI/dev box).

This is the desired graceful behavior; do not commit a fix.

  • Step 4: Smoke-run server with stub stdin

Run (Windows PowerShell):

echo '' | node dist/server.js

Expected: process stays alive a moment, exits cleanly when stdin closes. No stack traces.

  • Step 5: Commit any incidental fixes (probably none)

If fixes were needed, commit them now. Otherwise skip.


Task 4.2: Update README + record tasks

Files:

  • Modify: README.md

  • Modify: .tasks/STATUS.md

  • Create: .tasks/bootstrap-implementation.md

  • Modify: .wiki/index.md

  • Modify: .wiki/log.md

  • Create: .wiki/entities/sync-script.md

  • Create: .wiki/entities/mcp-server.md

  • Create: .wiki/entities/domain-detector.md

  • Create: .wiki/packages/modelcontextprotocol-sdk.md

  • Create: .wiki/packages/gray-matter.md

  • Create: .wiki/packages/smol-toml.md

  • Create: .wiki/packages/zod.md

  • Step 1: Replace README.md

# projects-meta-mcp

MCP-сервер: агрегирует статусы задач (`.tasks/STATUS.md`) и общие знания (репо `projects-wiki`, локально клонится в `~/projects/.wiki/`) поверх множества проектов и нескольких машин.

- Источник правды: Gitea (`https://git.kzntsv.site`, owner `OpeItcLoc03`).
- Процесс — локальный на каждой машине (offline-критично).
- Данные — локальный кэш + клон `projects-wiki`, синхронизируются скриптом.

## Bootstrap

```bash
git clone https://git.kzntsv.site/OpeItcLoc03/projects-meta-mcp ~/.local/projects-meta-mcp
cd ~/.local/projects-meta-mcp
npm install && npm run build

git clone https://git.kzntsv.site/OpeItcLoc03/projects-wiki ~/projects/.wiki

mkdir -p ~/.config/projects-mcp
cp auth.toml.example ~/.config/projects-mcp/auth.toml
# отредактировать auth.toml — заполнить gitea_token

node dist/sync.js   # первый sync

Регистрация в Claude Code

Добавить в ~/.claude.json:

{
  "mcpServers": {
    "projects-meta": {
      "command": "node",
      "args": ["C:/Users/<USER>/.local/projects-meta-mcp/dist/server.js"]
    }
  }
}

Tools

Tool Назначение
tasks.aggregate Свод активных задач по всем проектам
tasks.search Поиск по slug/next-action
tasks.get Полный STATUS.md одного проекта
knowledge.search Поиск по shared-вики, фильтр по домену cwd
knowledge.get Полный текст одной страницы
knowledge.suggest_promote Кандидаты на промоушен из .wiki/concepts/ текущего проекта
meta.status Возраст кэша + диагностика

См. дизайн: docs/superpowers/specs/2026-04-29-projects-meta-mcp-design.md План реализации: docs/superpowers/plans/2026-04-29-bootstrap-implementation.md


- [ ] **Step 2: Add task block to .tasks/STATUS.md**

After the comment block, append:

```markdown

## 🟢 [bootstrap-implementation] — Реализация sync-script + MCP-сервера по спеке

**Status:** done
**Where I stopped:** Все фазы плана выполнены, тесты зелёные, README обновлён.
**Next action:** Юзкейс-проверка на реальной Gitea: положить `auth.toml`, запустить `node dist/sync.js`, дёрнуть MCP из Claude Code.
**Branch:** main

---
  • Step 3: Create .tasks/bootstrap-implementation.md
# bootstrap-implementation

## Goal
Реализовать минимальный рабочий `projects-meta-mcp`: sync-script тащит `STATUS.md` всех репо `OpeItcLoc03` через Gitea API в локальный JSON-кэш; MCP-сервер по stdio отдаёт `tasks.*`, `knowledge.*`, `meta.status`. Конец = `npm test` зелёный, `npm run build` успешен, README с bootstrap-инструкцией.

## Key files
- `src/sync.ts` — CLI sync entry
- `src/server.ts` — MCP stdio entry
- `src/lib/config.ts:1` — paths + auth.toml
- `src/lib/gitea.ts:1` — Gitea API wrapper
- `src/lib/sync-runner.ts:1` — orchestrator (concurrency, error-collect)
- `src/lib/status-md.ts:1` — STATUS.md parser
- `src/lib/wiki-index.ts:1` — projects-wiki page loader
- `src/lib/domain-detector.ts:1` — cwd domain heuristic
- `src/lib/promotion.ts:1` — suggest_promote heuristic
- `src/tools/{tasks,knowledge,meta}.ts` — tool factories

## Decisions log
- 2026-04-29: ESM (`"type": "module"`) + NodeNext — ради native fetch и top-level await.
- 2026-04-29: Vitest вместо jest — нативный ESM, быстрее.
- 2026-04-29: `gray-matter` для frontmatter, `smol-toml` для auth.toml, `zod` для tool input validation.
- 2026-04-29: Concurrency limit 10 для Gitea — соответствует спеке.
- 2026-04-29: Stale threshold 24h в `meta.status` — соответствует спеке.

## Open questions
- [ ] Нужно ли в `getRawFile` пытаться `master` если `default_branch` не сработал? (пока: только `default_branch`).
- [ ] Cron / git-hook для запуска sync — вне scope этого таска.

## Completed steps
- [x] Phase 1 — bootstrap (package.json, tsconfig, vitest, auth.toml.example)
- [x] Phase 2 — sync (config, cache, status-md, gitea, sync-runner, sync.ts)
- [x] Phase 3 — server (stdio, tasks tools, domain-detector, knowledge tools, meta, promotion)
- [x] Phase 4 — wiring (README, .tasks, .wiki entities/packages)

## Notes
Pre-merge проверка: положить `~/.config/projects-mcp/auth.toml` с реальным токеном, запустить `node dist/sync.js`, проверить `~/.cache/projects-mcp/tasks.json`. Затем зарегистрировать MCP в `~/.claude.json` и дёрнуть `meta.status` из Claude Code.
  • Step 4: Update .wiki/index.md

Replace усто. Появятся при реализации сервера._ and усто. Появятся при npm install зависимостей._ with:

## Сущности

- [sync-script](entities/sync-script.md) — скрипт-синк Gitea API → JSON-кэш.
- [mcp-server](entities/mcp-server.md) — stdio MCP с тулами `tasks.*`, `knowledge.*`, `meta.*`.
- [domain-detector](entities/domain-detector.md) — определение домена cwd по файлам.

## Пакеты

- [@modelcontextprotocol/sdk](packages/modelcontextprotocol-sdk.md) — MCP runtime.
- [gray-matter](packages/gray-matter.md) — frontmatter parser.
- [smol-toml](packages/smol-toml.md) — TOML parser для auth.toml.
- [zod](packages/zod.md) — runtime валидация tool-инпутов.
  • Step 5: Create .wiki/entities/sync-script.md
---
title: sync-script
type: entity
tags: [gitea, cache, cli]
sources: []
updated: 2026-04-29
---

# sync-script

Локальный CLI-скрипт. Тащит `STATUS.md` всех репо пользователя через Gitea API, парсит, складывает в `~/.cache/projects-mcp/tasks.json` атомарной записью.

## Файлы

- [src/sync.ts](../../src/sync.ts) — entrypoint, читает `auth.toml`, вызывает runner, пишет кэш и лог.
- [src/lib/sync-runner.ts](../../src/lib/sync-runner.ts) — pure orchestrator: `listUserRepos` → параллельно (concurrency=10) `getRawFile` + `parseStatusMd` → собирает `CacheFile`. Тестируется юнитами через моки.
- [src/lib/gitea.ts](../../src/lib/gitea.ts) — fetch-wrapper, пагинация, 404 → `null`.
- [src/lib/cache.ts](../../src/lib/cache.ts) — атомарная запись через `tasks.json.tmp` + `rename`.

## Контракт

`runSync({ client, parseStatus, now, machine, giteaUrl, user, concurrency? })``CacheFile`.

Ошибки сетевых запросов на конкретный репо не валят весь синк — оседают в `errors[]`. 404 на `STATUS.md` = у проекта нет тасок (skip без ошибки).

## Запуск

```bash
node dist/sync.js

Exit codes:

  • 0 — sync прошёл (даже с частичными ошибками).
  • 2auth.toml отсутствует / битый.

- [ ] **Step 6: Create .wiki/entities/mcp-server.md**

```markdown
---
title: mcp-server
type: entity
tags: [mcp, stdio]
sources: []
updated: 2026-04-29
---

# mcp-server

stdio MCP-сервер. Не делает сетевых вызовов в hot path. Читает локальный кэш и клон `projects-wiki`.

## Tools

| Name | Файл |
|------|------|
| `tasks.aggregate`, `tasks.search`, `tasks.get` | [src/tools/tasks.ts](../../src/tools/tasks.ts) |
| `knowledge.search`, `knowledge.get`, `knowledge.suggest_promote` | [src/tools/knowledge.ts](../../src/tools/knowledge.ts) |
| `meta.status` | [src/tools/meta.ts](../../src/tools/meta.ts) |

## CLI args

- `--cwd <path>` — переопределение cwd (для тестов и nonstandard launchers). Иначе `process.cwd()`.

## Запуск

```bash
node dist/server.js

Транспорт — stdio. Регистрируется в ~/.claude.json как mcpServers.projects-meta.


- [ ] **Step 7: Create .wiki/entities/domain-detector.md**

```markdown
---
title: domain-detector
type: entity
tags: [heuristic]
sources: []
updated: 2026-04-29
---

# domain-detector

Определяет домен текущего проекта по файлам в cwd. Используется `knowledge.search` для дефолтного фильтра и `knowledge.suggest_promote` для предлагаемого `domain` в кандидате.

## Алгоритм

1. `package.json` → `node`
2. `platformio.ini` / `*.ino` / `CMakeLists.txt` с `arm-none-eabi`/`STM32`/`ESP_IDF` → `embedded`
3. `next.config.*` / `vite.config.*` / `index.html` (без package.json) → `web`
4. иначе → `unknown`

`package.json` имеет высший приоритет — node-проект со статиком всё равно `node`.

## Файл

- [src/lib/domain-detector.ts](../../src/lib/domain-detector.ts) — pure async function. Тесты — [tests/lib/domain-detector.test.ts](../../tests/lib/domain-detector.test.ts).
  • Step 8: Create package pages

.wiki/packages/modelcontextprotocol-sdk.md:

---
title: "@modelcontextprotocol/sdk"
type: package
tags: [mcp, runtime]
updated: 2026-04-29
---

# @modelcontextprotocol/sdk

Официальный TypeScript SDK для MCP. Используется только в [src/server.ts](../../src/server.ts).

- `Server` + `StdioServerTransport` для stdio-режима.
- Хендлеры регистрируются через `setRequestHandler(ListToolsRequestSchema | CallToolRequestSchema, ...)`.
- Для входных схем тулов используется JSON Schema (objects). Runtime-валидация — наша через `zod`.

.wiki/packages/gray-matter.md:

---
title: gray-matter
type: package
tags: [yaml, frontmatter]
updated: 2026-04-29
---

# gray-matter

Парсит YAML frontmatter в markdown. Используется в:
- [src/lib/wiki-index.ts](../../src/lib/wiki-index.ts) — `loadWiki()`
- [src/lib/promotion.ts](../../src/lib/promotion.ts) — `findCandidates()`

API: `matter(text) → { data, content }`.

.wiki/packages/smol-toml.md:

---
title: smol-toml
type: package
tags: [toml]
updated: 2026-04-29
---

# smol-toml

TOML парсер без зависимостей. Используется в [src/lib/config.ts](../../src/lib/config.ts) для `~/.config/projects-mcp/auth.toml`.

API: `parse(text) → object`.

Альтернатива `@iarna/toml` отвергнута — у smol-toml нет depend tree.

.wiki/packages/zod.md:

---
title: zod
type: package
tags: [validation]
updated: 2026-04-29
---

# zod

Runtime-валидация. Используется в:
- [src/lib/config.ts](../../src/lib/config.ts) — схема `auth.toml`
- [src/tools/tasks.ts](../../src/tools/tasks.ts) — input-схемы tasks-тулов
- [src/tools/knowledge.ts](../../src/tools/knowledge.ts) — input-схемы knowledge-тулов

JSON Schema, который видит MCP-клиент, прописан вручную (см. `inputSchema` каждого tool). zod — отдельный санитайзинг внутри handler'а, чтобы поймать малформед-аргументы.
  • Step 9: Append to .wiki/log.md

## [2026-04-29] init | Реализация sync-script + MCP-сервера

- Добавлены entity-страницы: sync-script, mcp-server, domain-detector.
- Добавлены package-страницы: @modelcontextprotocol/sdk, gray-matter, smol-toml, zod.
- index.md обновлён: сущности и пакеты больше не пустые.
- README.md теперь содержит bootstrap-инструкцию + tools-таблицу.
- Тесты зелёные, build чистый.
  • Step 10: Commit
git add README.md .tasks/STATUS.md .tasks/bootstrap-implementation.md .wiki/index.md .wiki/log.md .wiki/entities .wiki/packages
git commit -m "docs: README bootstrap guide + wiki entity/package pages + task closeout"

Task 4.3: Push to Gitea

  • Step 1: Push main
git push origin main

Expected: Multiple commits pushed cleanly. Verify on Gitea UI: git.kzntsv.site/OpeItcLoc03/projects-meta-mcp.

  • Step 2: No further commits in this plan.

Verification checklist (post-plan)

After all tasks done:

  • npm 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 lists task as 🟢 done
  • .wiki/index.md lists 3 entities + 4 packages

End state: pre-merge ready. To exercise live: write ~/.config/projects-mcp/auth.toml, run node dist/sync.js, register MCP in Claude Code, call meta.status from a chat session.