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>
174 lines
6.1 KiB
TypeScript
174 lines
6.1 KiB
TypeScript
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);
|
|
});
|
|
});
|