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:
2026-06-11 13:17:12 +03:00
parent 9eeae13e72
commit ca669d96e1
116 changed files with 25331 additions and 0 deletions

View 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');
});
});

View 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');
});
});

View 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');
});
});

View 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();
});
});

View File

@@ -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);
});
});

View 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({});
});
});

View 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);
});
});

View 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');
});
});

View 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');
});
});

File diff suppressed because it is too large Load Diff

View 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();
});
});