feat(lib): bundle MCP servers from .common/lib into factory/lib/
Copies source (no node_modules, dist, .tasks, .wiki, __pycache__) for: - projects-meta-mcp v2.25.0 (TypeScript/Node) - wiki-graph v0.3.1 (TypeScript/Node) - interns-mcp v0.3.3 (Python/FastMCP) .gitignore: exclude lib build artefacts (node_modules, dist, .venv, __pycache__, *.pyc) bootstrap.ps1: add MCP build step — npm install+build for TS servers, venv+pip for Python Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
475
lib/projects-meta-mcp/tests/tools/knowledge-ask-projects.test.ts
Normal file
475
lib/projects-meta-mcp/tests/tools/knowledge-ask-projects.test.ts
Normal file
@@ -0,0 +1,475 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { mkdtemp, mkdir } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { makeKnowledgeTools } from '../../src/tools/knowledge.js';
|
||||
import { writeCache, type CacheFile } from '../../src/lib/cache.js';
|
||||
import type { Backend, CommitFileArgs, FileWithSha } from '../../src/lib/backend.js';
|
||||
|
||||
let wikiRoot: string;
|
||||
let projectCwd: string;
|
||||
let cacheFile: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
wikiRoot = await mkdtemp(join(tmpdir(), 'kw-shared-'));
|
||||
await mkdir(wikiRoot, { recursive: true });
|
||||
projectCwd = await mkdtemp(join(tmpdir(), 'kw-cwd-'));
|
||||
cacheFile = join(await mkdtemp(join(tmpdir(), 'kw-cache-')), 'cache.json');
|
||||
});
|
||||
|
||||
interface FetchedPage {
|
||||
body: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pages are keyed by qualified `<owner>/<repo>` so the mock backend can verify
|
||||
* that handlers route fetches to the correct owner — the whole point of the
|
||||
* cross-owner regression in [multi-owner-ask-projects-owner-fix].
|
||||
*/
|
||||
function makeBackend(pages: Record<string, Record<string, FetchedPage | Error>>): Backend {
|
||||
return {
|
||||
async listUserRepos() {
|
||||
return [];
|
||||
},
|
||||
async getRawFile(owner, repo, path) {
|
||||
const repoPages = pages[`${owner}/${repo}`];
|
||||
if (!repoPages) return null;
|
||||
const v = repoPages[path];
|
||||
if (v === undefined) return null;
|
||||
if (v instanceof Error) throw v;
|
||||
return v.body;
|
||||
},
|
||||
async getFileWithSha(): Promise<FileWithSha | null> {
|
||||
return null;
|
||||
},
|
||||
async commitFile(_a: CommitFileArgs) {
|
||||
throw new Error('not used');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function seedCache(projects: CacheFile['projects']) {
|
||||
const file: CacheFile = {
|
||||
schemaVersion: 3,
|
||||
synced_at: '2026-04-30T00:00:00Z',
|
||||
synced_from: 'https://g',
|
||||
machine: 'm',
|
||||
projects,
|
||||
errors: [],
|
||||
};
|
||||
await writeCache(cacheFile, file);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
const sampleIndex = `# Wiki Index
|
||||
|
||||
## Concepts
|
||||
|
||||
- [yarn-on-windows](concepts/yarn-on-windows.md) — Yarn on Windows
|
||||
- [other](concepts/other.md) — Other Concept
|
||||
|
||||
## Raw
|
||||
|
||||
- [pdf-dump](raw/pdf-dump.md) — Heavy PDF
|
||||
`;
|
||||
|
||||
describe('knowledge.ask_projects', () => {
|
||||
it('returns matches across listed projects with snippets', async () => {
|
||||
await seedCache([
|
||||
{
|
||||
name: 'u/foo',
|
||||
default_branch: 'main',
|
||||
fetched_at: '2026-04-30T00:00:00Z',
|
||||
active_tasks: [],
|
||||
all_tasks_count: 0,
|
||||
raw: '',
|
||||
wiki_index: sampleIndex,
|
||||
},
|
||||
]);
|
||||
const backend = makeBackend({
|
||||
'u/foo': {
|
||||
'.wiki/concepts/yarn-on-windows.md': {
|
||||
body: '---\ntitle: Yarn on Windows\n---\n\nyarn install on windows is fiddly',
|
||||
},
|
||||
},
|
||||
});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
});
|
||||
const r = await call(tools, 'knowledge.ask_projects', {
|
||||
query: 'yarn',
|
||||
projects: ['u/foo'],
|
||||
});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.asked_projects).toEqual(['u/foo']);
|
||||
expect(parsed.results).toHaveLength(1);
|
||||
expect(parsed.results[0].project).toBe('u/foo');
|
||||
expect(parsed.results[0].matches).toHaveLength(1);
|
||||
expect(parsed.results[0].matches[0].slug).toBe('concepts/yarn-on-windows');
|
||||
expect(parsed.results[0].matches[0].type).toBe('concepts');
|
||||
expect(parsed.results[0].matches[0].title).toBe('Yarn on Windows');
|
||||
expect(parsed.results[0].matches[0].snippet).toContain('yarn');
|
||||
});
|
||||
|
||||
it('routes fetches to the correct owner across multiple owners (cross-owner)', async () => {
|
||||
await seedCache([
|
||||
{
|
||||
name: 'victor/books',
|
||||
default_branch: 'main',
|
||||
fetched_at: '2026-04-30T00:00:00Z',
|
||||
active_tasks: [],
|
||||
all_tasks_count: 0,
|
||||
raw: '',
|
||||
wiki_index: sampleIndex,
|
||||
},
|
||||
{
|
||||
name: 'OpeItcLoc03/common',
|
||||
default_branch: 'master',
|
||||
fetched_at: '2026-04-30T00:00:00Z',
|
||||
active_tasks: [],
|
||||
all_tasks_count: 0,
|
||||
raw: '',
|
||||
wiki_index: sampleIndex,
|
||||
},
|
||||
]);
|
||||
const calls: Array<{ owner: string; repo: string }> = [];
|
||||
const backend: Backend = {
|
||||
async listUserRepos() {
|
||||
return [];
|
||||
},
|
||||
async getRawFile(owner, repo, path) {
|
||||
calls.push({ owner, repo });
|
||||
if (owner === 'victor' && repo === 'books') {
|
||||
return path === '.wiki/concepts/yarn-on-windows.md' ? 'victor body about yarn' : null;
|
||||
}
|
||||
if (owner === 'OpeItcLoc03' && repo === 'common') {
|
||||
return path === '.wiki/concepts/yarn-on-windows.md' ? 'common body about yarn' : null;
|
||||
}
|
||||
// Reject acting-identity routing — proves the bug is gone.
|
||||
throw new Error(`unexpected fetch ${owner}/${repo}`);
|
||||
},
|
||||
async getFileWithSha(): Promise<FileWithSha | null> {
|
||||
return null;
|
||||
},
|
||||
async commitFile() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
};
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'OpeItcLoc03',
|
||||
});
|
||||
const r = await call(tools, 'knowledge.ask_projects', {
|
||||
query: 'yarn',
|
||||
projects: ['victor/books', 'OpeItcLoc03/common'],
|
||||
});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.results).toHaveLength(2);
|
||||
const victorResult = parsed.results.find((x: { project: string }) => x.project === 'victor/books');
|
||||
const commonResult = parsed.results.find((x: { project: string }) => x.project === 'OpeItcLoc03/common');
|
||||
expect(victorResult.matches[0].snippet).toContain('victor body');
|
||||
expect(commonResult.matches[0].snippet).toContain('common body');
|
||||
// Owner extracted from projectName, not from acting giteaUser.
|
||||
expect(calls.some((c) => c.owner === 'victor' && c.repo === 'books')).toBe(true);
|
||||
expect(calls.some((c) => c.owner === 'OpeItcLoc03' && c.repo === 'common')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects bare project name with parseTargetProject hint', async () => {
|
||||
await seedCache([]);
|
||||
const backend = makeBackend({});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
});
|
||||
const r = await call(tools, 'knowledge.ask_projects', {
|
||||
query: 'x',
|
||||
projects: ['books'],
|
||||
});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.results).toHaveLength(1);
|
||||
expect(parsed.results[0].project).toBe('books');
|
||||
expect(parsed.results[0].error).toContain('must be qualified');
|
||||
});
|
||||
|
||||
it('continues with valid projects when one is bare', async () => {
|
||||
await seedCache([
|
||||
{
|
||||
name: 'u/foo',
|
||||
default_branch: 'main',
|
||||
fetched_at: '2026-04-30T00:00:00Z',
|
||||
active_tasks: [],
|
||||
all_tasks_count: 0,
|
||||
raw: '',
|
||||
wiki_index: sampleIndex,
|
||||
},
|
||||
]);
|
||||
const backend = makeBackend({
|
||||
'u/foo': {
|
||||
'.wiki/concepts/yarn-on-windows.md': { body: 'yarn body' },
|
||||
},
|
||||
});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
});
|
||||
const r = await call(tools, 'knowledge.ask_projects', {
|
||||
query: 'yarn',
|
||||
projects: ['books', 'u/foo'],
|
||||
});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.results).toHaveLength(2);
|
||||
expect(parsed.results[0].error).toContain('must be qualified');
|
||||
expect(parsed.results[1].matches).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('rejects agenda literal with explanatory error', async () => {
|
||||
await seedCache([]);
|
||||
const backend = makeBackend({});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
});
|
||||
const r = await call(tools, 'knowledge.ask_projects', {
|
||||
query: 'x',
|
||||
projects: ['agenda'],
|
||||
});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.results[0].error).toContain('agenda not supported');
|
||||
});
|
||||
|
||||
it('reports "no wiki" for projects without wiki_index', async () => {
|
||||
await seedCache([
|
||||
{
|
||||
name: 'u/no-wiki',
|
||||
default_branch: 'main',
|
||||
fetched_at: '2026-04-30T00:00:00Z',
|
||||
active_tasks: [],
|
||||
all_tasks_count: 0,
|
||||
raw: '',
|
||||
},
|
||||
]);
|
||||
const backend = makeBackend({});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
});
|
||||
const r = await call(tools, 'knowledge.ask_projects', {
|
||||
query: 'anything',
|
||||
projects: ['u/no-wiki'],
|
||||
});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.results).toEqual([
|
||||
{ project: 'u/no-wiki', error: 'no wiki — repo has no .wiki/index.md' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports "unknown project" when project not in cache', async () => {
|
||||
await seedCache([]);
|
||||
const backend = makeBackend({});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
});
|
||||
const r = await call(tools, 'knowledge.ask_projects', {
|
||||
query: 'x',
|
||||
projects: ['u/ghost'],
|
||||
});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.results).toEqual([
|
||||
{ project: 'u/ghost', error: 'unknown project — run sync first' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('excludes raw type by default', async () => {
|
||||
await seedCache([
|
||||
{
|
||||
name: 'u/foo',
|
||||
default_branch: 'main',
|
||||
fetched_at: '2026-04-30T00:00:00Z',
|
||||
active_tasks: [],
|
||||
all_tasks_count: 0,
|
||||
raw: '',
|
||||
wiki_index: sampleIndex,
|
||||
},
|
||||
]);
|
||||
const backend = makeBackend({
|
||||
'u/foo': {
|
||||
'.wiki/raw/pdf-dump.md': { body: 'pdf about pdf' },
|
||||
},
|
||||
});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
});
|
||||
const r = await call(tools, 'knowledge.ask_projects', {
|
||||
query: 'pdf',
|
||||
projects: ['u/foo'],
|
||||
});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.results[0].matches).toEqual([]);
|
||||
});
|
||||
|
||||
it('includes raw when explicitly asked', async () => {
|
||||
await seedCache([
|
||||
{
|
||||
name: 'u/foo',
|
||||
default_branch: 'main',
|
||||
fetched_at: '2026-04-30T00:00:00Z',
|
||||
active_tasks: [],
|
||||
all_tasks_count: 0,
|
||||
raw: '',
|
||||
wiki_index: sampleIndex,
|
||||
},
|
||||
]);
|
||||
const backend = makeBackend({
|
||||
'u/foo': { '.wiki/raw/pdf-dump.md': { body: 'pdf body' } },
|
||||
});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
});
|
||||
const r = await call(tools, 'knowledge.ask_projects', {
|
||||
query: 'pdf',
|
||||
projects: ['u/foo'],
|
||||
types: ['raw'],
|
||||
});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.results[0].matches).toHaveLength(1);
|
||||
expect(parsed.results[0].matches[0].slug).toBe('raw/pdf-dump');
|
||||
});
|
||||
|
||||
it('respects limit per project', async () => {
|
||||
const wideIndex = `# Wiki Index\n\n## Concepts\n\n- [a](concepts/a.md) — A\n- [b](concepts/b.md) — B\n- [c](concepts/c.md) — C\n`;
|
||||
await seedCache([
|
||||
{
|
||||
name: 'u/foo',
|
||||
default_branch: 'main',
|
||||
fetched_at: '2026-04-30T00:00:00Z',
|
||||
active_tasks: [],
|
||||
all_tasks_count: 0,
|
||||
raw: '',
|
||||
wiki_index: wideIndex,
|
||||
},
|
||||
]);
|
||||
const backend = makeBackend({
|
||||
'u/foo': {
|
||||
'.wiki/concepts/a.md': { body: '' },
|
||||
'.wiki/concepts/b.md': { body: '' },
|
||||
'.wiki/concepts/c.md': { body: '' },
|
||||
},
|
||||
});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
});
|
||||
const r = await call(tools, 'knowledge.ask_projects', {
|
||||
query: '',
|
||||
projects: ['u/foo'],
|
||||
limit: 2,
|
||||
});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.results[0].matches).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('marks snippet as "(fetch failed)" when live fetch throws', async () => {
|
||||
await seedCache([
|
||||
{
|
||||
name: 'u/foo',
|
||||
default_branch: 'main',
|
||||
fetched_at: '2026-04-30T00:00:00Z',
|
||||
active_tasks: [],
|
||||
all_tasks_count: 0,
|
||||
raw: '',
|
||||
wiki_index: sampleIndex,
|
||||
},
|
||||
]);
|
||||
const backend = makeBackend({
|
||||
'u/foo': {
|
||||
'.wiki/concepts/yarn-on-windows.md': new Error('boom'),
|
||||
},
|
||||
});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
});
|
||||
const r = await call(tools, 'knowledge.ask_projects', {
|
||||
query: 'yarn',
|
||||
projects: ['u/foo'],
|
||||
});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.results[0].matches[0].snippet).toBe('(fetch failed)');
|
||||
expect(parsed.results[0].matches[0].slug).toBe('concepts/yarn-on-windows');
|
||||
});
|
||||
|
||||
it('returns error when backend not configured', async () => {
|
||||
await seedCache([]);
|
||||
const tools = makeKnowledgeTools({ wikiRoot, projectCwd, cacheFile });
|
||||
const r = await call(tools, 'knowledge.ask_projects', {
|
||||
query: 'x',
|
||||
projects: ['u/foo'],
|
||||
});
|
||||
expect(r.isError).toBe(true);
|
||||
expect(r.content[0].text).toContain('disabled');
|
||||
});
|
||||
|
||||
it('next_action_hint mentions both knowledge.get_from and tasks.create', async () => {
|
||||
await seedCache([]);
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd,
|
||||
cacheFile,
|
||||
backend: makeBackend({}),
|
||||
giteaUser: 'u',
|
||||
});
|
||||
const r = await call(tools, 'knowledge.ask_projects', {
|
||||
query: 'x',
|
||||
projects: ['u/foo'],
|
||||
});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.next_action_hint).toContain('knowledge.get_from');
|
||||
expect(parsed.next_action_hint).toContain('tasks.create');
|
||||
});
|
||||
});
|
||||
217
lib/projects-meta-mcp/tests/tools/knowledge-get-from.test.ts
Normal file
217
lib/projects-meta-mcp/tests/tools/knowledge-get-from.test.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { mkdtemp } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { makeKnowledgeTools } from '../../src/tools/knowledge.js';
|
||||
import { writeCache, type CacheFile } from '../../src/lib/cache.js';
|
||||
import type { Backend, CommitFileArgs, FileWithSha } from '../../src/lib/backend.js';
|
||||
|
||||
let wikiRoot: string;
|
||||
let projectCwd: string;
|
||||
let cacheFile: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
wikiRoot = await mkdtemp(join(tmpdir(), 'kw-shared-'));
|
||||
projectCwd = await mkdtemp(join(tmpdir(), 'kw-cwd-'));
|
||||
cacheFile = join(await mkdtemp(join(tmpdir(), 'kw-cache-')), 'cache.json');
|
||||
});
|
||||
|
||||
function makeBackend(files: Record<string, Record<string, string | null | Error>>): Backend {
|
||||
return {
|
||||
async listUserRepos() {
|
||||
return [];
|
||||
},
|
||||
async getRawFile(_u, repo, path) {
|
||||
const v = files[repo]?.[path];
|
||||
if (v === undefined) return null;
|
||||
if (v instanceof Error) throw v;
|
||||
return v;
|
||||
},
|
||||
async getFileWithSha(): Promise<FileWithSha | null> {
|
||||
return null;
|
||||
},
|
||||
async commitFile(_a: CommitFileArgs) {
|
||||
throw new Error('not used');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function seedCache(projects: CacheFile['projects']) {
|
||||
await writeCache(cacheFile, {
|
||||
schemaVersion: 3,
|
||||
synced_at: '2026-04-30T00:00:00Z',
|
||||
synced_from: 'https://g',
|
||||
machine: 'm',
|
||||
projects,
|
||||
errors: [],
|
||||
});
|
||||
}
|
||||
|
||||
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.get_from', () => {
|
||||
it('returns full page body for a known qualified project + slug', async () => {
|
||||
await seedCache([
|
||||
{
|
||||
name: 'victor/foo',
|
||||
default_branch: 'main',
|
||||
fetched_at: '2026-04-30T00:00:00Z',
|
||||
active_tasks: [],
|
||||
all_tasks_count: 0,
|
||||
raw: '',
|
||||
wiki_index: '',
|
||||
},
|
||||
]);
|
||||
const body = '---\ntitle: Foo\n---\n\nfoo body content';
|
||||
const backend = makeBackend({
|
||||
foo: { '.wiki/concepts/foo.md': body },
|
||||
});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
});
|
||||
const r = await call(tools, 'knowledge.get_from', {
|
||||
project: 'victor/foo',
|
||||
slug: 'concepts/foo',
|
||||
});
|
||||
expect(r.isError).toBeFalsy();
|
||||
expect(r.content[0].text).toBe(body);
|
||||
});
|
||||
|
||||
it('rejects bare project name with hint', async () => {
|
||||
await seedCache([]);
|
||||
const backend = makeBackend({});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
});
|
||||
const r = await call(tools, 'knowledge.get_from', {
|
||||
project: 'foo',
|
||||
slug: 'concepts/x',
|
||||
});
|
||||
expect(r.isError).toBe(true);
|
||||
expect(r.content[0].text).toContain('must be qualified');
|
||||
});
|
||||
|
||||
it('returns error when qualified project not in cache', async () => {
|
||||
await seedCache([]);
|
||||
const backend = makeBackend({});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
});
|
||||
const r = await call(tools, 'knowledge.get_from', {
|
||||
project: 'ghost/repo',
|
||||
slug: 'concepts/x',
|
||||
});
|
||||
expect(r.isError).toBe(true);
|
||||
expect(r.content[0].text).toContain('project not in cache');
|
||||
});
|
||||
|
||||
it('returns error when page does not exist', async () => {
|
||||
await seedCache([
|
||||
{
|
||||
name: 'victor/foo',
|
||||
default_branch: 'main',
|
||||
fetched_at: '2026-04-30T00:00:00Z',
|
||||
active_tasks: [],
|
||||
all_tasks_count: 0,
|
||||
raw: '',
|
||||
},
|
||||
]);
|
||||
const backend = makeBackend({ foo: {} });
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
});
|
||||
const r = await call(tools, 'knowledge.get_from', {
|
||||
project: 'victor/foo',
|
||||
slug: 'concepts/missing',
|
||||
});
|
||||
expect(r.isError).toBe(true);
|
||||
expect(r.content[0].text).toContain('page not found');
|
||||
});
|
||||
|
||||
it('resolves agenda alias to projects-wiki', async () => {
|
||||
await seedCache([
|
||||
{
|
||||
name: 'agenda',
|
||||
default_branch: 'main',
|
||||
fetched_at: '2026-04-30T00:00:00Z',
|
||||
active_tasks: [],
|
||||
all_tasks_count: 0,
|
||||
raw: '',
|
||||
},
|
||||
]);
|
||||
const body = '---\ntitle: Agenda\n---\n\nagenda content';
|
||||
// Shared wiki (projects-wiki) stores content at repo root, NO .wiki/ prefix.
|
||||
// Asymmetric with project wikis where pages live under <repo>/.wiki/.
|
||||
const backend = makeBackend({
|
||||
'projects-wiki': { 'concepts/test.md': body },
|
||||
});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
projectsWikiRepo: 'projects-wiki',
|
||||
});
|
||||
const r = await call(tools, 'knowledge.get_from', {
|
||||
project: 'agenda',
|
||||
slug: 'concepts/test',
|
||||
});
|
||||
expect(r.isError).toBeFalsy();
|
||||
expect(r.content[0].text).toBe(body);
|
||||
});
|
||||
|
||||
it('rejects legacy "_meta" literal with hint (renamed to "agenda" in 2.0)', async () => {
|
||||
await seedCache([]);
|
||||
const backend = makeBackend({});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
projectsWikiRepo: 'projects-wiki',
|
||||
});
|
||||
const r = await call(tools, 'knowledge.get_from', {
|
||||
project: '_meta',
|
||||
slug: 'concepts/test',
|
||||
});
|
||||
expect(r.isError).toBe(true);
|
||||
expect(r.content[0].text).toContain('must be qualified');
|
||||
});
|
||||
|
||||
it('returns error when backend not configured', async () => {
|
||||
await seedCache([]);
|
||||
const tools = makeKnowledgeTools({ wikiRoot, projectCwd, cacheFile });
|
||||
const r = await call(tools, 'knowledge.get_from', {
|
||||
project: 'victor/foo',
|
||||
slug: 'concepts/x',
|
||||
});
|
||||
expect(r.isError).toBe(true);
|
||||
expect(r.content[0].text).toContain('disabled');
|
||||
});
|
||||
});
|
||||
455
lib/projects-meta-mcp/tests/tools/knowledge.test.ts
Normal file
455
lib/projects-meta-mcp/tests/tools/knowledge.test.ts
Normal file
@@ -0,0 +1,455 @@
|
||||
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';
|
||||
import { writeCache, type CacheFile } from '../../src/lib/cache.js';
|
||||
import type { Backend, CommitFileArgs, FileWithSha } from '../../src/lib/backend.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);
|
||||
});
|
||||
});
|
||||
|
||||
describe('knowledge.suggest_promote', () => {
|
||||
it('returns candidates from project .wiki/concepts', async () => {
|
||||
await mkdir(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');
|
||||
});
|
||||
});
|
||||
|
||||
interface RecordedCommit extends CommitFileArgs {}
|
||||
|
||||
function recordingBackend(initial: Record<string, FileWithSha> = {}): {
|
||||
backend: Backend;
|
||||
commits: RecordedCommit[];
|
||||
files: Record<string, FileWithSha>;
|
||||
} {
|
||||
const files = { ...initial };
|
||||
const commits: RecordedCommit[] = [];
|
||||
const backend: Backend = {
|
||||
async listUserRepos() {
|
||||
return [];
|
||||
},
|
||||
async getRawFile(_u, repo, path) {
|
||||
return files[`${repo}:${path}`]?.content ?? null;
|
||||
},
|
||||
async getFileWithSha(_u, repo, path) {
|
||||
return files[`${repo}:${path}`] ?? null;
|
||||
},
|
||||
async commitFile(args) {
|
||||
commits.push({ ...args });
|
||||
const fileSha = `sha-${commits.length}`;
|
||||
files[`${args.repo}:${args.path}`] = { content: args.content, sha: fileSha };
|
||||
return { fileSha, commitSha: `commit-${commits.length}` };
|
||||
},
|
||||
};
|
||||
return { backend, commits, files };
|
||||
}
|
||||
|
||||
const fixedNow = () => new Date('2026-04-29T12:00:00Z');
|
||||
const fixture: CacheFile = {
|
||||
schemaVersion: 3,
|
||||
synced_at: '2026-04-29T12:00:00.000Z',
|
||||
synced_from: 'https://g',
|
||||
machine: 'm',
|
||||
projects: [
|
||||
{ name: 'OpeItcLoc03/alpha', default_branch: 'main', fetched_at: '2026-04-29T12:00:00Z', active_tasks: [], all_tasks_count: 0, raw: '' },
|
||||
{ name: 'victor/books', default_branch: 'main', fetched_at: '2026-04-29T12:00:00Z', active_tasks: [], all_tasks_count: 0, raw: '' },
|
||||
{ name: 'projects-wiki', default_branch: 'main', fetched_at: '2026-04-29T12:00:00Z', active_tasks: [], all_tasks_count: 0, raw: '' },
|
||||
],
|
||||
errors: [],
|
||||
};
|
||||
|
||||
async function makeCacheFile(): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'kw-cache-'));
|
||||
const f = join(dir, 'tasks.json');
|
||||
await writeCache(f, fixture);
|
||||
return f;
|
||||
}
|
||||
|
||||
describe('knowledge.ingest — preview', () => {
|
||||
it('returns preview without committing', async () => {
|
||||
const cacheFile = await makeCacheFile();
|
||||
const { backend, commits } = recordingBackend({});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd: nodeProject,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
projectsWikiRepo: 'projects-wiki',
|
||||
machine: 'host-x',
|
||||
sourceProject: 'modules-db',
|
||||
now: fixedNow,
|
||||
});
|
||||
const r = await call(tools, 'knowledge.ingest', {
|
||||
target_project: 'OpeItcLoc03/alpha',
|
||||
type: 'concepts',
|
||||
slug: 'tasks-routing-rule',
|
||||
body: 'Read local STATUS.md for current project.',
|
||||
});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.preview).toBe(true);
|
||||
expect(parsed.target_owner).toBe('OpeItcLoc03');
|
||||
expect(parsed.target_repo).toBe('alpha');
|
||||
expect(parsed.target_path).toBe('.wiki/concepts/tasks-routing-rule.md');
|
||||
expect(parsed.content).toContain('title: tasks-routing-rule');
|
||||
expect(parsed.content).toContain('source_project: modules-db');
|
||||
expect(commits).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects bare target_project with hint', async () => {
|
||||
const cacheFile = await makeCacheFile();
|
||||
const { backend, commits } = recordingBackend({});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd: nodeProject,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
projectsWikiRepo: 'projects-wiki',
|
||||
now: fixedNow,
|
||||
});
|
||||
const r = await call(tools, 'knowledge.ingest', {
|
||||
target_project: 'alpha',
|
||||
type: 'concepts',
|
||||
slug: 'x',
|
||||
body: 'b',
|
||||
});
|
||||
expect(r.isError).toBe(true);
|
||||
expect(r.content[0].text).toContain('must be qualified');
|
||||
expect(commits).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('knowledge.ingest — confirm', () => {
|
||||
it('writes page + index + log (3 commits) and removes (none yet) placeholder', async () => {
|
||||
const cacheFile = await makeCacheFile();
|
||||
const { backend, commits, files } = recordingBackend({});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd: nodeProject,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
projectsWikiRepo: 'projects-wiki',
|
||||
machine: 'host-x',
|
||||
sourceProject: 'modules-db',
|
||||
now: fixedNow,
|
||||
});
|
||||
const r = await call(tools, 'knowledge.ingest', {
|
||||
target_project: 'OpeItcLoc03/alpha',
|
||||
type: 'concepts',
|
||||
slug: 'routing-rule',
|
||||
body: 'rule body',
|
||||
frontmatter: { title: 'Tasks Routing Rule', tags: ['routing'] },
|
||||
confirm: true,
|
||||
});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.committed).toBe(true);
|
||||
expect(parsed.target_owner).toBe('OpeItcLoc03');
|
||||
expect(parsed.target_repo).toBe('alpha');
|
||||
expect(parsed.commits).toHaveLength(3);
|
||||
const paths = parsed.commits.map((c: { path: string }) => c.path);
|
||||
expect(paths).toEqual(['.wiki/concepts/routing-rule.md', '.wiki/index.md', '.wiki/log.md']);
|
||||
expect(files['alpha:.wiki/concepts/routing-rule.md'].content).toContain('Tasks Routing Rule');
|
||||
expect(files['alpha:.wiki/index.md'].content).toContain('- [routing-rule](concepts/routing-rule.md) — Tasks Routing Rule');
|
||||
expect(files['alpha:.wiki/log.md'].content).toContain('## [2026-04-29] ingest | concepts/routing-rule');
|
||||
// Three commits = page + index + log
|
||||
expect(commits).toHaveLength(3);
|
||||
expect(commits[0].user).toBe('OpeItcLoc03');
|
||||
expect(commits[0].repo).toBe('alpha');
|
||||
});
|
||||
|
||||
it('routes victor/books to owner=victor', async () => {
|
||||
const cacheFile = await makeCacheFile();
|
||||
const { backend, commits } = recordingBackend({});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd: nodeProject,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
projectsWikiRepo: 'projects-wiki',
|
||||
machine: 'host-x',
|
||||
sourceProject: 'modules-db',
|
||||
now: fixedNow,
|
||||
});
|
||||
const r = await call(tools, 'knowledge.ingest', {
|
||||
target_project: 'victor/books',
|
||||
type: 'concepts',
|
||||
slug: 'pagination',
|
||||
body: 'how to paginate',
|
||||
confirm: true,
|
||||
});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.committed).toBe(true);
|
||||
expect(parsed.target_owner).toBe('victor');
|
||||
expect(parsed.target_repo).toBe('books');
|
||||
expect(commits[0].user).toBe('victor');
|
||||
expect(commits[0].repo).toBe('books');
|
||||
});
|
||||
|
||||
it('rejects when page already exists', async () => {
|
||||
const cacheFile = await makeCacheFile();
|
||||
const { backend, commits } = recordingBackend({
|
||||
'alpha:.wiki/concepts/x.md': { content: '---\ntitle: x\n---\n', sha: 's' },
|
||||
});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd: nodeProject,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
projectsWikiRepo: 'projects-wiki',
|
||||
now: fixedNow,
|
||||
});
|
||||
const r = await call(tools, 'knowledge.ingest', {
|
||||
target_project: 'OpeItcLoc03/alpha',
|
||||
type: 'concepts',
|
||||
slug: 'x',
|
||||
body: 'b',
|
||||
confirm: true,
|
||||
});
|
||||
expect(r.isError).toBe(true);
|
||||
expect(r.content[0].text).toContain('already exists');
|
||||
expect(commits).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects unknown target_project (cache miss)', async () => {
|
||||
const cacheFile = await makeCacheFile();
|
||||
const { backend, commits } = recordingBackend({});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd: nodeProject,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
projectsWikiRepo: 'projects-wiki',
|
||||
});
|
||||
const r = await call(tools, 'knowledge.ingest', {
|
||||
target_project: 'ghost/repo',
|
||||
type: 'concepts',
|
||||
slug: 's',
|
||||
body: 'b',
|
||||
confirm: true,
|
||||
});
|
||||
expect(r.isError).toBe(true);
|
||||
expect(r.content[0].text).toContain('not in cache');
|
||||
expect(commits).toEqual([]);
|
||||
});
|
||||
|
||||
it('write tools disabled when no backend/auth', async () => {
|
||||
const tools = makeKnowledgeTools({ wikiRoot, projectCwd: nodeProject });
|
||||
const r = await call(tools, 'knowledge.ingest', {
|
||||
target_project: 'OpeItcLoc03/alpha',
|
||||
type: 'concepts',
|
||||
slug: 's',
|
||||
body: 'b',
|
||||
});
|
||||
expect(r.isError).toBe(true);
|
||||
expect(r.content[0].text).toContain('Write tools disabled');
|
||||
});
|
||||
});
|
||||
|
||||
describe('knowledge.promote', () => {
|
||||
it('preview returns sources content with raw_path frontmatter', async () => {
|
||||
const cacheFile = await makeCacheFile();
|
||||
const { backend, commits } = recordingBackend({
|
||||
'alpha:.wiki/raw/x.md': { content: '# raw\n', sha: 'r' },
|
||||
});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd: nodeProject,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
projectsWikiRepo: 'projects-wiki',
|
||||
now: fixedNow,
|
||||
});
|
||||
const r = await call(tools, 'knowledge.promote', {
|
||||
target_project: 'OpeItcLoc03/alpha',
|
||||
slug: 'x',
|
||||
body: 'summary of raw',
|
||||
});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.preview).toBe(true);
|
||||
expect(parsed.target_owner).toBe('OpeItcLoc03');
|
||||
expect(parsed.target_repo).toBe('alpha');
|
||||
expect(parsed.target_path).toBe('.wiki/sources/x.md');
|
||||
expect(parsed.content).toContain('raw_path: raw/x.md');
|
||||
expect(commits).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects bare target_project', async () => {
|
||||
const cacheFile = await makeCacheFile();
|
||||
const { backend, commits } = recordingBackend({});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd: nodeProject,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
projectsWikiRepo: 'projects-wiki',
|
||||
now: fixedNow,
|
||||
});
|
||||
const r = await call(tools, 'knowledge.promote', {
|
||||
target_project: 'alpha',
|
||||
slug: 'x',
|
||||
body: 'b',
|
||||
confirm: true,
|
||||
});
|
||||
expect(r.isError).toBe(true);
|
||||
expect(r.content[0].text).toContain('must be qualified');
|
||||
expect(commits).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects when raw page is missing', async () => {
|
||||
const cacheFile = await makeCacheFile();
|
||||
const { backend, commits } = recordingBackend({});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd: nodeProject,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
projectsWikiRepo: 'projects-wiki',
|
||||
now: fixedNow,
|
||||
});
|
||||
const r = await call(tools, 'knowledge.promote', {
|
||||
target_project: 'OpeItcLoc03/alpha',
|
||||
slug: 'missing',
|
||||
body: 'b',
|
||||
confirm: true,
|
||||
});
|
||||
expect(r.isError).toBe(true);
|
||||
expect(r.content[0].text).toContain('raw page not found');
|
||||
expect(commits).toEqual([]);
|
||||
});
|
||||
|
||||
it('confirm writes sources + updates index/log', async () => {
|
||||
const cacheFile = await makeCacheFile();
|
||||
const { backend, commits, files } = recordingBackend({
|
||||
'alpha:.wiki/raw/notes.md': { content: '# notes\n', sha: 'r' },
|
||||
});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd: nodeProject,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
projectsWikiRepo: 'projects-wiki',
|
||||
now: fixedNow,
|
||||
});
|
||||
const r = await call(tools, 'knowledge.promote', {
|
||||
target_project: 'OpeItcLoc03/alpha',
|
||||
slug: 'notes',
|
||||
body: 'summary text',
|
||||
frontmatter: { title: 'Notes Summary' },
|
||||
confirm: true,
|
||||
});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.committed).toBe(true);
|
||||
expect(parsed.target_owner).toBe('OpeItcLoc03');
|
||||
expect(parsed.target_repo).toBe('alpha');
|
||||
expect(parsed.commits.map((c: { path: string }) => c.path)).toEqual([
|
||||
'.wiki/sources/notes.md',
|
||||
'.wiki/index.md',
|
||||
'.wiki/log.md',
|
||||
]);
|
||||
expect(files['alpha:.wiki/sources/notes.md'].content).toContain('raw_path: raw/notes.md');
|
||||
expect(files['alpha:.wiki/log.md'].content).toContain('promote | raw/notes → sources/notes');
|
||||
expect(commits).toHaveLength(3);
|
||||
expect(commits[0].user).toBe('OpeItcLoc03');
|
||||
expect(commits[0].repo).toBe('alpha');
|
||||
});
|
||||
});
|
||||
153
lib/projects-meta-mcp/tests/tools/meta.test.ts
Normal file
153
lib/projects-meta-mcp/tests/tools/meta.test.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
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'),
|
||||
agendaTasksDir: join(home, 'tasks'),
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
expect(parsed.agenda_present).toBe(false);
|
||||
expect(parsed.agenda_tasks_dir).toBe(paths.agendaTasksDir);
|
||||
});
|
||||
|
||||
it('reports age and stale=false for fresh cache', async () => {
|
||||
await writeCache(paths.cacheFile, {
|
||||
schemaVersion: 3,
|
||||
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, {
|
||||
schemaVersion: 3,
|
||||
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);
|
||||
});
|
||||
|
||||
it('exposes agenda-project counts and source=local when agenda default_branch is "local"', async () => {
|
||||
await writeCache(paths.cacheFile, {
|
||||
schemaVersion: 3,
|
||||
synced_at: new Date().toISOString(),
|
||||
synced_from: 'https://g',
|
||||
machine: 'm',
|
||||
projects: [
|
||||
{ name: 'p', default_branch: 'main', fetched_at: new Date().toISOString(), active_tasks: [], all_tasks_count: 0, raw: '' },
|
||||
{
|
||||
name: 'agenda',
|
||||
default_branch: 'local',
|
||||
fetched_at: new Date().toISOString(),
|
||||
active_tasks: [
|
||||
{ slug: 'm1', status: 'active', next: 'do x' },
|
||||
{ slug: 'm2', status: 'paused', next: 'do y' },
|
||||
],
|
||||
all_tasks_count: 3,
|
||||
raw: '...',
|
||||
},
|
||||
],
|
||||
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.agenda_present).toBe(true);
|
||||
expect(parsed.agenda_tasks_active).toBe(2);
|
||||
expect(parsed.agenda_tasks_total).toBe(3);
|
||||
expect(parsed.agenda_source).toBe('local');
|
||||
expect(parsed.agenda_branch).toBe('local');
|
||||
});
|
||||
|
||||
it('reports agenda_source=gitea when agenda default_branch is a real branch', async () => {
|
||||
await writeCache(paths.cacheFile, {
|
||||
schemaVersion: 3,
|
||||
synced_at: new Date().toISOString(),
|
||||
synced_from: 'https://g',
|
||||
machine: 'm',
|
||||
projects: [
|
||||
{
|
||||
name: 'agenda',
|
||||
default_branch: 'main',
|
||||
fetched_at: new Date().toISOString(),
|
||||
active_tasks: [{ slug: 'g1', status: 'active', next: 'x' }],
|
||||
all_tasks_count: 1,
|
||||
raw: '...',
|
||||
},
|
||||
],
|
||||
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.agenda_present).toBe(true);
|
||||
expect(parsed.agenda_source).toBe('gitea');
|
||||
expect(parsed.agenda_branch).toBe('main');
|
||||
});
|
||||
|
||||
it('reports agenda_source=none when agenda absent', async () => {
|
||||
await writeCache(paths.cacheFile, {
|
||||
schemaVersion: 3,
|
||||
synced_at: new Date().toISOString(),
|
||||
synced_from: 'https://g',
|
||||
machine: 'm',
|
||||
projects: [{ name: 'p', default_branch: 'main', fetched_at: new Date().toISOString(), active_tasks: [], all_tasks_count: 0, raw: '' }],
|
||||
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.agenda_present).toBe(false);
|
||||
expect(parsed.agenda_source).toBe('none');
|
||||
expect(parsed.agenda_branch).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
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';
|
||||
import type { Backend, CommitFileArgs, CommitResult, FileWithSha } from '../../src/lib/backend.js';
|
||||
|
||||
function recordingBackend(initial: Record<string, FileWithSha> = {}) {
|
||||
const files = { ...initial };
|
||||
const commits: CommitFileArgs[] = [];
|
||||
const backend: Backend = {
|
||||
async listUserRepos() {
|
||||
return [];
|
||||
},
|
||||
async getRawFile(_u, repo, path) {
|
||||
return files[`${repo}:${path}`]?.content ?? null;
|
||||
},
|
||||
async getFileWithSha(_u, repo, path) {
|
||||
return files[`${repo}:${path}`] ?? null;
|
||||
},
|
||||
async commitFile(args: CommitFileArgs): Promise<CommitResult> {
|
||||
commits.push({ ...args });
|
||||
const fileSha = `sha-${commits.length}`;
|
||||
files[`${args.repo}:${args.path}`] = { content: args.content, sha: fileSha };
|
||||
return { fileSha, commitSha: `commit-${commits.length}` };
|
||||
},
|
||||
};
|
||||
return { backend, commits, files };
|
||||
}
|
||||
|
||||
const fixture: CacheFile = {
|
||||
schemaVersion: 3,
|
||||
synced_at: '2026-06-08T12:00:00.000Z',
|
||||
synced_from: 'https://g',
|
||||
machine: 'm',
|
||||
projects: [
|
||||
{
|
||||
name: 'OpeItcLoc03/alpha',
|
||||
default_branch: 'main',
|
||||
fetched_at: '2026-06-08T12:00:01.000Z',
|
||||
active_tasks: [],
|
||||
all_tasks_count: 0,
|
||||
raw: '',
|
||||
},
|
||||
],
|
||||
errors: [],
|
||||
};
|
||||
|
||||
let cacheFile: string;
|
||||
const fixedNow = () => new Date('2026-06-08T10:00:00.000Z');
|
||||
|
||||
beforeEach(async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'trail-tool-'));
|
||||
cacheFile = join(dir, 'tasks.json');
|
||||
await writeCache(cacheFile, fixture);
|
||||
});
|
||||
|
||||
function makeTools(backend: Backend) {
|
||||
return makeTasksTools({
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
agendaTasksRepo: 'projects-tasks',
|
||||
machine: 'host-x',
|
||||
now: fixedNow,
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
const base = {
|
||||
target_project: 'OpeItcLoc03/alpha',
|
||||
slug: 'my-task',
|
||||
question: 'cache key includes locale?',
|
||||
decided_by: 'arbiter',
|
||||
ruling: 'include locale',
|
||||
rationale: 'avoids cross-locale bleed',
|
||||
blast_radius: 'reversible',
|
||||
escalation_chain: ['brief'],
|
||||
};
|
||||
|
||||
describe('tasks.append_decision_trail', () => {
|
||||
it('is registered', () => {
|
||||
const { backend } = recordingBackend();
|
||||
const tools = makeTools(backend);
|
||||
expect(tools.find((t) => t.name === 'tasks.append_decision_trail')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('commits straight (no confirm gate — infra append) and targets .tasks/<slug>.md', async () => {
|
||||
const { backend, commits } = recordingBackend({
|
||||
'alpha:.tasks/my-task.md': { content: '# my-task\n\n## Goal\nx\n', sha: 'old' },
|
||||
});
|
||||
const tools = makeTools(backend);
|
||||
const r = await call(tools, 'tasks.append_decision_trail', base);
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
|
||||
expect(parsed.committed).toBe(true);
|
||||
expect(commits).toHaveLength(1);
|
||||
expect(commits[0].path).toBe('.tasks/my-task.md');
|
||||
expect(commits[0].sha).toBe('old'); // updated existing file via sha lock
|
||||
expect(commits[0].content).toContain('## Decision trail');
|
||||
expect(commits[0].content).toContain('### consult 1 —');
|
||||
});
|
||||
|
||||
it('returns a trail_ref pointer the worker can be handed', async () => {
|
||||
const { backend } = recordingBackend({
|
||||
'alpha:.tasks/my-task.md': { content: '# my-task\n', sha: 'old' },
|
||||
});
|
||||
const tools = makeTools(backend);
|
||||
const r = await call(tools, 'tasks.append_decision_trail', base);
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(typeof parsed.trail_ref).toBe('string');
|
||||
expect(parsed.trail_ref).toContain('my-task');
|
||||
});
|
||||
|
||||
it('creates the task file when it does not exist yet (no sha)', async () => {
|
||||
const { backend, commits } = recordingBackend({});
|
||||
const tools = makeTools(backend);
|
||||
const r = await call(tools, 'tasks.append_decision_trail', base);
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.committed).toBe(true);
|
||||
expect(commits[0].sha).toBeUndefined(); // create, not update
|
||||
expect(commits[0].content).toContain('# my-task');
|
||||
expect(commits[0].content).toContain('### consult 1 —');
|
||||
});
|
||||
|
||||
it('writes every contract field', async () => {
|
||||
const { backend, commits } = recordingBackend({});
|
||||
const tools = makeTools(backend);
|
||||
await call(tools, 'tasks.append_decision_trail', base);
|
||||
const c = commits[0].content;
|
||||
expect(c).toContain('- question: cache key includes locale?');
|
||||
expect(c).toContain('- blast_radius: reversible');
|
||||
expect(c).toContain('- decided_by: arbiter');
|
||||
expect(c).toContain('- ruling: include locale');
|
||||
expect(c).toContain('- rationale: avoids cross-locale bleed');
|
||||
expect(c).toContain('- escalation_chain: brief');
|
||||
});
|
||||
|
||||
it('numbers a second consult as 2', async () => {
|
||||
const { backend, commits } = recordingBackend({});
|
||||
const tools = makeTools(backend);
|
||||
await call(tools, 'tasks.append_decision_trail', base);
|
||||
await call(tools, 'tasks.append_decision_trail', { ...base, question: 'second?' });
|
||||
expect(commits[1].content).toContain('### consult 2 —');
|
||||
});
|
||||
|
||||
it('logs a halt outcome (decided_by human-required, no ruling)', async () => {
|
||||
const { backend, commits } = recordingBackend({});
|
||||
const tools = makeTools(backend);
|
||||
await call(tools, 'tasks.append_decision_trail', {
|
||||
target_project: 'OpeItcLoc03/alpha',
|
||||
slug: 'my-task',
|
||||
question: 'priorities?',
|
||||
decided_by: 'human-required',
|
||||
rationale: 'escalated: arbiter declared a fork',
|
||||
escalation_chain: ['brief', 'arbiter-fork'],
|
||||
});
|
||||
expect(commits[0].content).toContain('- decided_by: human-required');
|
||||
expect(commits[0].content).toContain('- ruling: —');
|
||||
});
|
||||
|
||||
it('is disabled without a backend', async () => {
|
||||
const tools = makeTasksTools({ cacheFile });
|
||||
const r = await call(tools, 'tasks.append_decision_trail', base);
|
||||
expect(r.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
373
lib/projects-meta-mcp/tests/tools/tasks-cli.test.ts
Normal file
373
lib/projects-meta-mcp/tests/tools/tasks-cli.test.ts
Normal file
@@ -0,0 +1,373 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { runTasksCli } from '../../src/tasks-cli.js';
|
||||
import type { ToolDef, ToolResult } from '../../src/tools/types.js';
|
||||
|
||||
function fakeTool(
|
||||
name: string,
|
||||
impl: (args: Record<string, unknown>) => ToolResult,
|
||||
): ToolDef {
|
||||
return {
|
||||
name,
|
||||
description: '',
|
||||
inputSchema: { type: 'object' },
|
||||
handler: async (args) => impl(args),
|
||||
};
|
||||
}
|
||||
|
||||
function jsonResult(value: unknown): ToolResult {
|
||||
return { content: [{ type: 'text', text: JSON.stringify(value) }] };
|
||||
}
|
||||
|
||||
describe('runTasksCli', () => {
|
||||
it('routes claim-next to tasks.claim_next with parsed flags and prints the handler JSON', async () => {
|
||||
let received: Record<string, unknown> | undefined;
|
||||
const tools = [
|
||||
fakeTool('tasks.claim_next', (args) => {
|
||||
received = args;
|
||||
return jsonResult({ ok: true, claimed: true, slug: 'foo' });
|
||||
}),
|
||||
];
|
||||
const out: string[] = [];
|
||||
|
||||
const code = await runTasksCli(
|
||||
[
|
||||
'claim-next',
|
||||
'--claimer-identity',
|
||||
'host:claude-opus:123',
|
||||
'--runtime',
|
||||
'claude-opus',
|
||||
'--requirements',
|
||||
'needs-db,needs-internet',
|
||||
'--confirm',
|
||||
],
|
||||
{ tools, log: (s) => out.push(s) },
|
||||
);
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(received).toEqual({
|
||||
claimer_identity: 'host:claude-opus:123',
|
||||
confirm: true,
|
||||
filter: { runtime: 'claude-opus', requirements: ['needs-db', 'needs-internet'] },
|
||||
});
|
||||
expect(JSON.parse(out.join('\n'))).toEqual({ ok: true, claimed: true, slug: 'foo' });
|
||||
});
|
||||
|
||||
it('routes append-decision-trail to tasks.append_decision_trail, splitting the chain', async () => {
|
||||
let received: Record<string, unknown> | undefined;
|
||||
const tools = [
|
||||
fakeTool('tasks.append_decision_trail', (args) => {
|
||||
received = args;
|
||||
return jsonResult({ committed: true, trail_ref: 'x' });
|
||||
}),
|
||||
];
|
||||
const code = await runTasksCli(
|
||||
[
|
||||
'append-decision-trail',
|
||||
'--target-project',
|
||||
'OpeItcLoc03/alpha',
|
||||
'--slug',
|
||||
'my-task',
|
||||
'--question',
|
||||
'cache key includes locale?',
|
||||
'--decided-by',
|
||||
'arbiter',
|
||||
'--blast-radius',
|
||||
'reversible',
|
||||
'--ruling',
|
||||
'include locale',
|
||||
'--rationale',
|
||||
'avoids bleed',
|
||||
'--escalation-chain',
|
||||
'brief,read-repo',
|
||||
],
|
||||
{ tools, log: () => {} },
|
||||
);
|
||||
expect(code).toBe(0);
|
||||
expect(received).toEqual({
|
||||
target_project: 'OpeItcLoc03/alpha',
|
||||
slug: 'my-task',
|
||||
question: 'cache key includes locale?',
|
||||
decided_by: 'arbiter',
|
||||
blast_radius: 'reversible',
|
||||
ruling: 'include locale',
|
||||
rationale: 'avoids bleed',
|
||||
escalation_chain: ['brief', 'read-repo'],
|
||||
});
|
||||
});
|
||||
|
||||
it('omits ruling on a halt trail entry', async () => {
|
||||
let received: Record<string, unknown> | undefined;
|
||||
const tools = [
|
||||
fakeTool('tasks.append_decision_trail', (args) => {
|
||||
received = args;
|
||||
return jsonResult({ committed: true });
|
||||
}),
|
||||
];
|
||||
await runTasksCli(
|
||||
[
|
||||
'append-decision-trail',
|
||||
'--target-project',
|
||||
'OpeItcLoc03/alpha',
|
||||
'--slug',
|
||||
'my-task',
|
||||
'--question',
|
||||
'priorities?',
|
||||
'--decided-by',
|
||||
'human-required',
|
||||
'--escalation-chain',
|
||||
'brief,arbiter-fork',
|
||||
],
|
||||
{ tools, log: () => {} },
|
||||
);
|
||||
expect(received).not.toHaveProperty('ruling');
|
||||
expect(received!.decided_by).toBe('human-required');
|
||||
});
|
||||
|
||||
it('routes park-question to tasks.park_question with question + optional claim_token', async () => {
|
||||
let received: Record<string, unknown> | undefined;
|
||||
const tools = [
|
||||
fakeTool('tasks.park_question', (args) => {
|
||||
received = args;
|
||||
return jsonResult({ parked: true });
|
||||
}),
|
||||
];
|
||||
const code = await runTasksCli(
|
||||
[
|
||||
'park-question',
|
||||
'--target-project',
|
||||
'OpeItcLoc03/alpha',
|
||||
'--slug',
|
||||
'my-task',
|
||||
'--question',
|
||||
'drop the legacy column?',
|
||||
'--claim-token',
|
||||
'tok-123',
|
||||
],
|
||||
{ tools, log: () => {} },
|
||||
);
|
||||
expect(code).toBe(0);
|
||||
expect(received).toEqual({
|
||||
target_project: 'OpeItcLoc03/alpha',
|
||||
slug: 'my-task',
|
||||
question: 'drop the legacy column?',
|
||||
claim_token: 'tok-123',
|
||||
});
|
||||
});
|
||||
|
||||
it('routes get-status to tasks.get_status', async () => {
|
||||
let received: Record<string, unknown> | undefined;
|
||||
const tools = [
|
||||
fakeTool('tasks.get_status', (args) => {
|
||||
received = args;
|
||||
return jsonResult({ found: true, status: 'blocked' });
|
||||
}),
|
||||
];
|
||||
const code = await runTasksCli(
|
||||
['get-status', '--target-project', 'OpeItcLoc03/alpha', '--slug', 'my-task'],
|
||||
{ tools, log: () => {} },
|
||||
);
|
||||
expect(code).toBe(0);
|
||||
expect(received).toEqual({ target_project: 'OpeItcLoc03/alpha', slug: 'my-task' });
|
||||
});
|
||||
|
||||
it('routes heartbeat to tasks.heartbeat with slug + claim_token (+ optional target_project)', async () => {
|
||||
let received: Record<string, unknown> | undefined;
|
||||
const tools = [
|
||||
fakeTool('tasks.heartbeat', (args) => {
|
||||
received = args;
|
||||
return jsonResult({ ok: true });
|
||||
}),
|
||||
];
|
||||
const code = await runTasksCli(
|
||||
['heartbeat', '--slug', 'foo', '--claim-token', 'tok-1', '--target-project', 'victor/books'],
|
||||
{ tools, log: () => {} },
|
||||
);
|
||||
expect(code).toBe(0);
|
||||
expect(received).toEqual({ slug: 'foo', claim_token: 'tok-1', target_project: 'victor/books' });
|
||||
});
|
||||
|
||||
it('routes close to tasks.close with target_project + slug + note + confirm', async () => {
|
||||
let received: Record<string, unknown> | undefined;
|
||||
const tools = [
|
||||
fakeTool('tasks.close', (args) => {
|
||||
received = args;
|
||||
return jsonResult({ committed: true });
|
||||
}),
|
||||
];
|
||||
const code = await runTasksCli(
|
||||
['close', '--target-project', 'victor/books', '--slug', 'foo', '--note', 'done via poller', '--confirm'],
|
||||
{ tools, log: () => {} },
|
||||
);
|
||||
expect(code).toBe(0);
|
||||
expect(received).toEqual({
|
||||
target_project: 'victor/books',
|
||||
slug: 'foo',
|
||||
note: 'done via poller',
|
||||
confirm: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('routes update to tasks.update with target_project + slug + status + where_stopped', async () => {
|
||||
let received: Record<string, unknown> | undefined;
|
||||
const tools = [
|
||||
fakeTool('tasks.update', (args) => {
|
||||
received = args;
|
||||
return jsonResult({ committed: true });
|
||||
}),
|
||||
];
|
||||
const code = await runTasksCli(
|
||||
[
|
||||
'update',
|
||||
'--target-project',
|
||||
'victor/books',
|
||||
'--slug',
|
||||
'foo',
|
||||
'--status',
|
||||
'blocked',
|
||||
'--where-stopped',
|
||||
'spawn exited 1: boom',
|
||||
'--confirm',
|
||||
],
|
||||
{ tools, log: () => {} },
|
||||
);
|
||||
expect(code).toBe(0);
|
||||
expect(received).toEqual({
|
||||
target_project: 'victor/books',
|
||||
slug: 'foo',
|
||||
status: 'blocked',
|
||||
where_stopped: 'spawn exited 1: boom',
|
||||
confirm: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('passes an explicit empty --blocker= through as blocker:"" (clear the blocker)', async () => {
|
||||
// The ops watchdog resets a transient-blocked task with `--status=ready
|
||||
// --blocker=`; the empty value must reach the handler so updateTaskFields
|
||||
// strips the stale **Blocker:** line, not be dropped as a falsy flag.
|
||||
let received: Record<string, unknown> | undefined;
|
||||
const tools = [
|
||||
fakeTool('tasks.update', (args) => {
|
||||
received = args;
|
||||
return jsonResult({ committed: true });
|
||||
}),
|
||||
];
|
||||
const code = await runTasksCli(
|
||||
['update', '--target-project=victor/books', '--slug=foo', '--status=ready', '--blocker=', '--confirm'],
|
||||
{ tools, log: () => {} },
|
||||
);
|
||||
expect(code).toBe(0);
|
||||
expect(received).toEqual({
|
||||
target_project: 'victor/books',
|
||||
slug: 'foo',
|
||||
status: 'ready',
|
||||
blocker: '',
|
||||
confirm: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('omits blocker entirely when --blocker is absent', async () => {
|
||||
let received: Record<string, unknown> | undefined;
|
||||
const tools = [
|
||||
fakeTool('tasks.update', (args) => {
|
||||
received = args;
|
||||
return jsonResult({ committed: true });
|
||||
}),
|
||||
];
|
||||
const code = await runTasksCli(
|
||||
['update', '--target-project=victor/books', '--slug=foo', '--status=ready', '--confirm'],
|
||||
{ tools, log: () => {} },
|
||||
);
|
||||
expect(code).toBe(0);
|
||||
expect(received).not.toHaveProperty('blocker');
|
||||
});
|
||||
|
||||
it('returns exit code 1 when the handler result isError', async () => {
|
||||
const tools = [
|
||||
fakeTool('tasks.claim_next', () => ({
|
||||
content: [{ type: 'text', text: 'cache missing — run sync first' }],
|
||||
isError: true,
|
||||
})),
|
||||
];
|
||||
const out: string[] = [];
|
||||
const code = await runTasksCli(['claim-next', '--claimer-identity', 'h:r:1'], {
|
||||
tools,
|
||||
log: (s) => out.push(s),
|
||||
});
|
||||
expect(code).toBe(1);
|
||||
expect(out.join('\n')).toContain('cache missing');
|
||||
});
|
||||
|
||||
it('returns exit code 2 and usage on an unknown command', async () => {
|
||||
const err: string[] = [];
|
||||
const code = await runTasksCli(['frobnicate'], { tools: [], errLog: (s) => err.push(s) });
|
||||
expect(code).toBe(2);
|
||||
expect(err.join('\n')).toContain('unknown command: frobnicate');
|
||||
});
|
||||
|
||||
it('a throwing handler (e.g. zod parse on a missing required flag) → JSON + non-zero, not an uncaught throw', async () => {
|
||||
const tools = [
|
||||
fakeTool('tasks.claim_next', () => {
|
||||
throw new Error('claimer_identity: Required');
|
||||
}),
|
||||
];
|
||||
const out: string[] = [];
|
||||
const code = await runTasksCli(['claim-next'], { tools, log: (s) => out.push(s) });
|
||||
expect(code).not.toBe(0);
|
||||
const parsed = JSON.parse(out.join('\n'));
|
||||
expect(parsed.ok).toBe(false);
|
||||
expect(parsed.error).toContain('claimer_identity');
|
||||
});
|
||||
|
||||
it('--flag=value form: a value beginning with -- survives (blocked-path where_stopped)', async () => {
|
||||
let received: Record<string, unknown> | undefined;
|
||||
const tools = [
|
||||
fakeTool('tasks.update', (args) => {
|
||||
received = args;
|
||||
return jsonResult({ committed: true });
|
||||
}),
|
||||
];
|
||||
const code = await runTasksCli(
|
||||
['update', '--target-project=victor/books', '--slug=foo', '--status=blocked', '--where-stopped=--boom: bad flag', '--confirm'],
|
||||
{ tools, log: () => {} },
|
||||
);
|
||||
expect(code).toBe(0);
|
||||
expect(received).toEqual({
|
||||
target_project: 'victor/books',
|
||||
slug: 'foo',
|
||||
status: 'blocked',
|
||||
where_stopped: '--boom: bad flag',
|
||||
confirm: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('routes list-blocked to tasks.list_blocked with optional project_allowlist', async () => {
|
||||
let received: Record<string, unknown> | undefined;
|
||||
const tools = [
|
||||
fakeTool('tasks.list_blocked', (args) => {
|
||||
received = args;
|
||||
return jsonResult({ ok: true, tasks: [] });
|
||||
}),
|
||||
];
|
||||
const out: string[] = [];
|
||||
const code = await runTasksCli(
|
||||
['list-blocked', '--projects', 'OpeItcLoc03/alpha,OpeItcLoc03/beta'],
|
||||
{ tools, log: (s) => out.push(s) },
|
||||
);
|
||||
expect(code).toBe(0);
|
||||
expect(received).toEqual({ project_allowlist: ['OpeItcLoc03/alpha', 'OpeItcLoc03/beta'] });
|
||||
expect(JSON.parse(out.join('\n'))).toEqual({ ok: true, tasks: [] });
|
||||
});
|
||||
|
||||
it('routes list-blocked without --projects (no allowlist)', async () => {
|
||||
let received: Record<string, unknown> | undefined;
|
||||
const tools = [
|
||||
fakeTool('tasks.list_blocked', (args) => {
|
||||
received = args;
|
||||
return jsonResult({ ok: true, tasks: [] });
|
||||
}),
|
||||
];
|
||||
const code = await runTasksCli(['list-blocked'], { tools, log: () => {} });
|
||||
expect(code).toBe(0);
|
||||
expect(received).toEqual({});
|
||||
});
|
||||
});
|
||||
102
lib/projects-meta-mcp/tests/tools/tasks-get-status.test.ts
Normal file
102
lib/projects-meta-mcp/tests/tools/tasks-get-status.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
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';
|
||||
import type { Backend, CommitFileArgs, CommitResult, FileWithSha } from '../../src/lib/backend.js';
|
||||
|
||||
function recordingBackend(initial: Record<string, FileWithSha> = {}) {
|
||||
const files = { ...initial };
|
||||
const backend: Backend = {
|
||||
async listUserRepos() {
|
||||
return [];
|
||||
},
|
||||
async getRawFile(_u, repo, path) {
|
||||
return files[`${repo}:${path}`]?.content ?? null;
|
||||
},
|
||||
async getFileWithSha(_u, repo, path) {
|
||||
return files[`${repo}:${path}`] ?? null;
|
||||
},
|
||||
async commitFile(args: CommitFileArgs): Promise<CommitResult> {
|
||||
return { fileSha: 'x', commitSha: 'y' };
|
||||
},
|
||||
};
|
||||
return { backend, files };
|
||||
}
|
||||
|
||||
const fixture: CacheFile = {
|
||||
schemaVersion: 3,
|
||||
synced_at: '2026-06-08T12:00:00.000Z',
|
||||
synced_from: 'https://g',
|
||||
machine: 'm',
|
||||
projects: [
|
||||
{
|
||||
name: 'OpeItcLoc03/alpha',
|
||||
default_branch: 'main',
|
||||
fetched_at: '2026-06-08T12:00:01.000Z',
|
||||
active_tasks: [],
|
||||
all_tasks_count: 0,
|
||||
raw: '',
|
||||
},
|
||||
],
|
||||
errors: [],
|
||||
};
|
||||
|
||||
const BOARD =
|
||||
'# Task Board\n\n## 🔵 [parked-task] — x\n\n**Status:** blocked\n**Branch:** master\n\n---\n' +
|
||||
'## 🔴 [live-task] — y\n\n**Status:** active\n**Branch:** master\n\n---\n';
|
||||
|
||||
let cacheFile: string;
|
||||
beforeEach(async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'getstatus-'));
|
||||
cacheFile = join(dir, 'tasks.json');
|
||||
await writeCache(cacheFile, fixture);
|
||||
});
|
||||
|
||||
function makeTools(backend: Backend) {
|
||||
return makeTasksTools({ cacheFile, backend, giteaUser: 'u', agendaTasksRepo: 'projects-tasks' });
|
||||
}
|
||||
function call(tools: ReturnType<typeof makeTasksTools>, name: string, args: Record<string, unknown>) {
|
||||
const t = tools.find((x) => x.name === name)!;
|
||||
return t.handler(args);
|
||||
}
|
||||
|
||||
describe('tasks.get_status', () => {
|
||||
it('returns the LIVE status of a task (reads the board, not the cache)', async () => {
|
||||
const { backend } = recordingBackend({ 'alpha:.tasks/STATUS.md': { content: BOARD, sha: 's' } });
|
||||
const r = await call(makeTools(backend), 'tasks.get_status', {
|
||||
target_project: 'OpeItcLoc03/alpha',
|
||||
slug: 'parked-task',
|
||||
});
|
||||
expect(JSON.parse(r.content[0].text).status).toBe('blocked');
|
||||
});
|
||||
|
||||
it('distinguishes an active task', async () => {
|
||||
const { backend } = recordingBackend({ 'alpha:.tasks/STATUS.md': { content: BOARD, sha: 's' } });
|
||||
const r = await call(makeTools(backend), 'tasks.get_status', {
|
||||
target_project: 'OpeItcLoc03/alpha',
|
||||
slug: 'live-task',
|
||||
});
|
||||
expect(JSON.parse(r.content[0].text).status).toBe('active');
|
||||
});
|
||||
|
||||
it('reports not-found for an absent slug', async () => {
|
||||
const { backend } = recordingBackend({ 'alpha:.tasks/STATUS.md': { content: BOARD, sha: 's' } });
|
||||
const r = await call(makeTools(backend), 'tasks.get_status', {
|
||||
target_project: 'OpeItcLoc03/alpha',
|
||||
slug: 'ghost',
|
||||
});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.status).toBeNull();
|
||||
expect(parsed.found).toBe(false);
|
||||
});
|
||||
|
||||
it('is disabled without a backend', async () => {
|
||||
const r = await call(makeTasksTools({ cacheFile }), 'tasks.get_status', {
|
||||
target_project: 'OpeItcLoc03/alpha',
|
||||
slug: 'live-task',
|
||||
});
|
||||
expect(r.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
197
lib/projects-meta-mcp/tests/tools/tasks-list-blocked.test.ts
Normal file
197
lib/projects-meta-mcp/tests/tools/tasks-list-blocked.test.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
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';
|
||||
import type { Backend, FileWithSha } from '../../src/lib/backend.js';
|
||||
|
||||
function recordingBackend(initial: Record<string, FileWithSha> = {}) {
|
||||
const files = { ...initial };
|
||||
const backend: Backend = {
|
||||
async listUserRepos() { return []; },
|
||||
async getRawFile(_u, repo, path) { return files[`${repo}:${path}`]?.content ?? null; },
|
||||
async getFileWithSha(_u, repo, path) { return files[`${repo}:${path}`] ?? null; },
|
||||
async commitFile() { return { fileSha: 'x', commitSha: 'y' }; },
|
||||
};
|
||||
return { backend, files };
|
||||
}
|
||||
|
||||
const ALPHA_BOARD = `# Task Board
|
||||
_Updated: 2026-06-09_
|
||||
|
||||
## 🔵 [blocked-cheap] — blocked cheap task
|
||||
**Status:** blocked
|
||||
**Where I stopped:** dep pending
|
||||
**Next action:** wait
|
||||
**Blocker:** dep-a, dep-b
|
||||
**Branch:** master
|
||||
**Weight:** cheap-ok
|
||||
|
||||
---
|
||||
|
||||
## 🔵 [blocked-claude] — blocked claude task
|
||||
**Status:** blocked
|
||||
**Where I stopped:** dep pending
|
||||
**Next action:** wait
|
||||
**Blocker:** dep-c
|
||||
**Branch:** master
|
||||
**Weight:** needs-claude
|
||||
|
||||
---
|
||||
|
||||
## 🔵 [blocked-human] — blocked human task (should be skipped)
|
||||
**Status:** blocked
|
||||
**Where I stopped:** dep pending
|
||||
**Next action:** wait
|
||||
**Blocker:** dep-d
|
||||
**Branch:** master
|
||||
**Weight:** needs-human
|
||||
|
||||
---
|
||||
|
||||
## 🔵 [blocked-noweight] — blocked no-weight task (should be skipped)
|
||||
**Status:** blocked
|
||||
**Where I stopped:** dep pending
|
||||
**Next action:** wait
|
||||
**Blocker:** dep-e
|
||||
**Branch:** master
|
||||
|
||||
---
|
||||
|
||||
## ⚪ [ready-task] — not blocked, should not appear
|
||||
**Status:** ready
|
||||
**Next action:** go
|
||||
**Branch:** master
|
||||
|
||||
---
|
||||
`;
|
||||
|
||||
const BETA_BOARD = `# Task Board
|
||||
_Updated: 2026-06-09_
|
||||
|
||||
## 🔵 [blocked-beta] — blocked beta task
|
||||
**Status:** blocked
|
||||
**Where I stopped:** waiting
|
||||
**Next action:** wait
|
||||
**Blocker:** dep-x
|
||||
**Branch:** master
|
||||
**Weight:** needs-claude
|
||||
|
||||
---
|
||||
`;
|
||||
|
||||
const fixture: CacheFile = {
|
||||
schemaVersion: 3,
|
||||
synced_at: '2026-06-09T12:00:00.000Z',
|
||||
synced_from: 'https://g',
|
||||
machine: 'm',
|
||||
projects: [
|
||||
{
|
||||
name: 'OpeItcLoc03/alpha',
|
||||
default_branch: 'main',
|
||||
fetched_at: '2026-06-09T12:00:00.000Z',
|
||||
active_tasks: [
|
||||
{ slug: 'blocked-cheap', status: 'blocked', next: 'wait' },
|
||||
{ slug: 'blocked-claude', status: 'blocked', next: 'wait' },
|
||||
{ slug: 'blocked-human', status: 'blocked', next: 'wait' },
|
||||
{ slug: 'blocked-noweight', status: 'blocked', next: 'wait' },
|
||||
],
|
||||
all_tasks_count: 5,
|
||||
raw: '',
|
||||
},
|
||||
{
|
||||
name: 'OpeItcLoc03/beta',
|
||||
default_branch: 'main',
|
||||
fetched_at: '2026-06-09T12:00:00.000Z',
|
||||
active_tasks: [
|
||||
{ slug: 'blocked-beta', status: 'blocked', next: 'wait' },
|
||||
],
|
||||
all_tasks_count: 1,
|
||||
raw: '',
|
||||
},
|
||||
],
|
||||
errors: [],
|
||||
};
|
||||
|
||||
let cacheFile: string;
|
||||
beforeEach(async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'listblocked-'));
|
||||
cacheFile = join(dir, 'tasks.json');
|
||||
await writeCache(cacheFile, fixture);
|
||||
});
|
||||
|
||||
function makeTools(backend: Backend) {
|
||||
return makeTasksTools({ cacheFile, backend, giteaUser: 'u', agendaTasksRepo: 'projects-tasks' });
|
||||
}
|
||||
function call(tools: ReturnType<typeof makeTasksTools>, name: string, args: Record<string, unknown>) {
|
||||
const t = tools.find((x) => x.name === name)!;
|
||||
return t.handler(args);
|
||||
}
|
||||
|
||||
describe('tasks.list_blocked', () => {
|
||||
it('returns blocked tasks with weight needs-claude or cheap-ok, skips needs-human and no-weight', async () => {
|
||||
const { backend } = recordingBackend({
|
||||
'alpha:.tasks/STATUS.md': { content: ALPHA_BOARD, sha: 's' },
|
||||
'beta:.tasks/STATUS.md': { content: BETA_BOARD, sha: 's' },
|
||||
});
|
||||
const r = await call(makeTools(backend), 'tasks.list_blocked', {});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.ok).toBe(true);
|
||||
const slugs = parsed.tasks.map((t: { slug: string }) => t.slug).sort();
|
||||
expect(slugs).toEqual(['blocked-beta', 'blocked-cheap', 'blocked-claude']);
|
||||
});
|
||||
|
||||
it('includes blocker and weight fields', async () => {
|
||||
const { backend } = recordingBackend({
|
||||
'alpha:.tasks/STATUS.md': { content: ALPHA_BOARD, sha: 's' },
|
||||
'beta:.tasks/STATUS.md': { content: BETA_BOARD, sha: 's' },
|
||||
});
|
||||
const r = await call(makeTools(backend), 'tasks.list_blocked', {});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
const cheap = parsed.tasks.find((t: { slug: string }) => t.slug === 'blocked-cheap');
|
||||
expect(cheap.blocker).toBe('dep-a, dep-b');
|
||||
expect(cheap.weight).toBe('cheap-ok');
|
||||
expect(cheap.project).toBe('OpeItcLoc03/alpha');
|
||||
const claude = parsed.tasks.find((t: { slug: string }) => t.slug === 'blocked-claude');
|
||||
expect(claude.blocker).toBe('dep-c');
|
||||
expect(claude.weight).toBe('needs-claude');
|
||||
});
|
||||
|
||||
it('respects project_allowlist — only returns tasks from listed projects', async () => {
|
||||
const { backend } = recordingBackend({
|
||||
'alpha:.tasks/STATUS.md': { content: ALPHA_BOARD, sha: 's' },
|
||||
'beta:.tasks/STATUS.md': { content: BETA_BOARD, sha: 's' },
|
||||
});
|
||||
const r = await call(makeTools(backend), 'tasks.list_blocked', {
|
||||
project_allowlist: ['OpeItcLoc03/beta'],
|
||||
});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.ok).toBe(true);
|
||||
expect(parsed.tasks.every((t: { project: string }) => t.project === 'OpeItcLoc03/beta')).toBe(true);
|
||||
expect(parsed.tasks.map((t: { slug: string }) => t.slug)).toEqual(['blocked-beta']);
|
||||
});
|
||||
|
||||
it('returns empty tasks when cache is missing', async () => {
|
||||
const { backend } = recordingBackend({});
|
||||
const tools = makeTasksTools({ cacheFile: '/nonexistent/tasks.json', backend, giteaUser: 'u', agendaTasksRepo: 'projects-tasks' });
|
||||
const t = tools.find((x) => x.name === 'tasks.list_blocked')!;
|
||||
const r = await t.handler({});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.tasks).toEqual([]);
|
||||
expect(parsed.cache_missing).toBe(true);
|
||||
});
|
||||
|
||||
it('skips project when STATUS.md not found in backend (graceful degradation)', async () => {
|
||||
const { backend } = recordingBackend({
|
||||
'beta:.tasks/STATUS.md': { content: BETA_BOARD, sha: 's' },
|
||||
// alpha STATUS.md is missing
|
||||
});
|
||||
const r = await call(makeTools(backend), 'tasks.list_blocked', {});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.ok).toBe(true);
|
||||
const slugs = parsed.tasks.map((t: { slug: string }) => t.slug);
|
||||
expect(slugs).toContain('blocked-beta');
|
||||
expect(slugs).not.toContain('blocked-cheap');
|
||||
});
|
||||
});
|
||||
174
lib/projects-meta-mcp/tests/tools/tasks-park-question.test.ts
Normal file
174
lib/projects-meta-mcp/tests/tools/tasks-park-question.test.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
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';
|
||||
import type { Backend, CommitFileArgs, CommitResult, FileWithSha } from '../../src/lib/backend.js';
|
||||
|
||||
function recordingBackend(initial: Record<string, FileWithSha> = {}) {
|
||||
const files = { ...initial };
|
||||
const commits: CommitFileArgs[] = [];
|
||||
const backend: Backend = {
|
||||
async listUserRepos() {
|
||||
return [];
|
||||
},
|
||||
async getRawFile(_u, repo, path) {
|
||||
return files[`${repo}:${path}`]?.content ?? null;
|
||||
},
|
||||
async getFileWithSha(_u, repo, path) {
|
||||
return files[`${repo}:${path}`] ?? null;
|
||||
},
|
||||
async commitFile(args: CommitFileArgs): Promise<CommitResult> {
|
||||
commits.push({ ...args });
|
||||
const fileSha = `sha-${commits.length}`;
|
||||
files[`${args.repo}:${args.path}`] = { content: args.content, sha: fileSha };
|
||||
return { fileSha, commitSha: `commit-${commits.length}` };
|
||||
},
|
||||
};
|
||||
return { backend, commits, files };
|
||||
}
|
||||
|
||||
const fixture: CacheFile = {
|
||||
schemaVersion: 3,
|
||||
synced_at: '2026-06-08T12:00:00.000Z',
|
||||
synced_from: 'https://g',
|
||||
machine: 'm',
|
||||
projects: [
|
||||
{
|
||||
name: 'OpeItcLoc03/alpha',
|
||||
default_branch: 'main',
|
||||
fetched_at: '2026-06-08T12:00:01.000Z',
|
||||
active_tasks: [],
|
||||
all_tasks_count: 0,
|
||||
raw: '',
|
||||
},
|
||||
],
|
||||
errors: [],
|
||||
};
|
||||
|
||||
const ACTIVE_BOARD =
|
||||
'# Task Board\n_Updated: 2026-06-08_\n\n## 🔴 [my-task] — A thing\n\n' +
|
||||
'**Status:** active\n**Where I stopped:** mid-flight\n**Next action:** keep going\n' +
|
||||
'**Branch:** master\n**Owner:** host:claude-opus:1\n**Claim token:** tok-123\n\n---\n';
|
||||
|
||||
let cacheFile: string;
|
||||
const fixedNow = () => new Date('2026-06-08T10:00:00.000Z');
|
||||
|
||||
beforeEach(async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'park-tool-'));
|
||||
cacheFile = join(dir, 'tasks.json');
|
||||
await writeCache(cacheFile, fixture);
|
||||
});
|
||||
|
||||
function makeTools(backend: Backend) {
|
||||
return makeTasksTools({
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
agendaTasksRepo: 'projects-tasks',
|
||||
machine: 'host-x',
|
||||
now: fixedNow,
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
const base = {
|
||||
target_project: 'OpeItcLoc03/alpha',
|
||||
slug: 'my-task',
|
||||
question: 'Should we drop the legacy column? (irreversible)',
|
||||
};
|
||||
|
||||
describe('tasks.park_question', () => {
|
||||
it('is registered', () => {
|
||||
const { backend } = recordingBackend();
|
||||
expect(makeTools(backend).find((t) => t.name === 'tasks.park_question')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('flips status to blocked and writes the pending question (atomic, single commit)', async () => {
|
||||
const { backend, commits } = recordingBackend({
|
||||
'alpha:.tasks/STATUS.md': { content: ACTIVE_BOARD, sha: 'old' },
|
||||
});
|
||||
const tools = makeTools(backend);
|
||||
const r = await call(tools, 'tasks.park_question', base);
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
|
||||
expect(parsed.parked).toBe(true);
|
||||
expect(commits).toHaveLength(1);
|
||||
expect(commits[0].path).toBe('.tasks/STATUS.md');
|
||||
expect(commits[0].content).toContain('## 🔵 [my-task]');
|
||||
expect(commits[0].content).toContain('**Status:** blocked');
|
||||
expect(commits[0].content).toContain('Should we drop the legacy column?');
|
||||
});
|
||||
|
||||
it('commits straight (no confirm gate — infra mutation)', async () => {
|
||||
const { backend, commits } = recordingBackend({
|
||||
'alpha:.tasks/STATUS.md': { content: ACTIVE_BOARD, sha: 'old' },
|
||||
});
|
||||
const tools = makeTools(backend);
|
||||
await call(tools, 'tasks.park_question', base); // no confirm flag
|
||||
expect(commits).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('with a matching claim_token, parks (token-gated like heartbeat)', async () => {
|
||||
const { backend, commits } = recordingBackend({
|
||||
'alpha:.tasks/STATUS.md': { content: ACTIVE_BOARD, sha: 'old' },
|
||||
});
|
||||
const tools = makeTools(backend);
|
||||
const r = await call(tools, 'tasks.park_question', { ...base, claim_token: 'tok-123' });
|
||||
expect(JSON.parse(r.content[0].text).parked).toBe(true);
|
||||
expect(commits).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('rejects a claim_token mismatch without committing (someone else holds the claim)', async () => {
|
||||
const { backend, commits } = recordingBackend({
|
||||
'alpha:.tasks/STATUS.md': { content: ACTIVE_BOARD, sha: 'old' },
|
||||
});
|
||||
const tools = makeTools(backend);
|
||||
const r = await call(tools, 'tasks.park_question', { ...base, claim_token: 'WRONG' });
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.ok).toBe(false);
|
||||
expect(parsed.reason).toBe('token-mismatch');
|
||||
expect(commits).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('errors when the task is not on the board', async () => {
|
||||
const { backend } = recordingBackend({
|
||||
'alpha:.tasks/STATUS.md': { content: ACTIVE_BOARD, sha: 'old' },
|
||||
});
|
||||
const tools = makeTools(backend);
|
||||
const r = await call(tools, 'tasks.park_question', { ...base, slug: 'ghost' });
|
||||
expect(r.isError).toBe(true);
|
||||
});
|
||||
|
||||
it('is disabled without a backend', async () => {
|
||||
const tools = makeTasksTools({ cacheFile });
|
||||
const r = await call(tools, 'tasks.park_question', base);
|
||||
expect(r.isError).toBe(true);
|
||||
});
|
||||
|
||||
it('calls inboxWriter with event:blocked when task has Notify field', async () => {
|
||||
const boardWithNotify =
|
||||
'# Task Board\n_Updated: 2026-06-08_\n\n## 🔴 [my-task] — A thing\n\n' +
|
||||
'**Status:** active\n**Where I stopped:** mid-flight\n**Next action:** keep going\n' +
|
||||
'**Branch:** master\n**Owner:** host:claude-opus:1\n**Claim token:** tok-123\n' +
|
||||
'**Notify:** OpeItcLoc03/workshop\n\n---\n';
|
||||
const { backend } = recordingBackend({
|
||||
'alpha:.tasks/STATUS.md': { content: boardWithNotify, sha: 'old' },
|
||||
});
|
||||
const inboxCalls: unknown[] = [];
|
||||
const inboxWriter = async (args: unknown) => { inboxCalls.push(args); };
|
||||
const tools = makeTasksTools({ cacheFile, backend, giteaUser: 'u', agendaTasksRepo: 'pt', now: fixedNow, inboxWriter });
|
||||
await call(tools, 'tasks.park_question', base);
|
||||
|
||||
expect(inboxCalls).toHaveLength(1);
|
||||
expect((inboxCalls[0] as Record<string, string>).event).toBe('blocked');
|
||||
expect((inboxCalls[0] as Record<string, string>).notify).toBe('OpeItcLoc03/workshop');
|
||||
expect((inboxCalls[0] as Record<string, string>).slug).toBe('my-task');
|
||||
});
|
||||
});
|
||||
1473
lib/projects-meta-mcp/tests/tools/tasks.test.ts
Normal file
1473
lib/projects-meta-mcp/tests/tools/tasks.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
49
lib/projects-meta-mcp/tests/tools/workers.test.ts
Normal file
49
lib/projects-meta-mcp/tests/tools/workers.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { makeWorkersTools } from '../../src/tools/workers.js';
|
||||
|
||||
describe('worker.register (stub)', () => {
|
||||
const tool = makeWorkersTools().find((t) => t.name === 'worker.register')!;
|
||||
|
||||
it('exists and documents itself as a stub', () => {
|
||||
expect(tool).toBeDefined();
|
||||
expect(tool.description.toLowerCase()).toContain('stub');
|
||||
});
|
||||
|
||||
it('returns a 501 not-implemented verdict for valid input', async () => {
|
||||
const r = await tool.handler({
|
||||
machine: 'win11',
|
||||
runtime: 'claude-opus',
|
||||
capabilities: ['needs-db'],
|
||||
});
|
||||
expect(r.isError).toBe(true);
|
||||
const body = JSON.parse(r.content[0].text);
|
||||
expect(body.ok).toBe(false);
|
||||
expect(body.reason).toBe('not-implemented');
|
||||
expect(typeof body.hint).toBe('string');
|
||||
expect(body.hint.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('keeps the verdict for optional endpoint + confirm (no side-effects)', async () => {
|
||||
const r = await tool.handler({
|
||||
machine: 'win11',
|
||||
runtime: 'claude-opus',
|
||||
capabilities: [],
|
||||
endpoint: 'https://node/agent',
|
||||
confirm: true,
|
||||
});
|
||||
const body = JSON.parse(r.content[0].text);
|
||||
expect(body.reason).toBe('not-implemented');
|
||||
});
|
||||
|
||||
it('rejects malformed input — missing machine', async () => {
|
||||
await expect(
|
||||
tool.handler({ runtime: 'claude-opus', capabilities: [] }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('rejects malformed input — capabilities not an array', async () => {
|
||||
await expect(
|
||||
tool.handler({ machine: 'm', runtime: 'r', capabilities: 'nope' }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user