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